Files
jellyfin-plugin-enigma2/deploy-plugin-only.py
T

267 lines
11 KiB
Python

"""Deploy only Enigma2 after an explicitly approved Jellyfin maintenance window."""
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import re
import shutil
import subprocess
import time
import urllib.error
import urllib.request
import uuid
VERSION = '7.0.9.0'
ABI = '12.2.0.0'
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
DLL = 'Jellyfin.Plugin.Enigma2.dll'
PLUGINS = pathlib.Path('/var/lib/jellyfin/plugins')
BACKUPS = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')
LOGS = pathlib.Path('/var/log/jellyfin')
PROTECTED = tuple(map(pathlib.Path, (
'/etc/default/jellyfin', '/etc/jellyfin/encoding.xml',
'/usr/local/lib/jellyfin-enigma/ffmpeg', '/usr/local/lib/jellyfin-enigma/ffprobe',
'/usr/share/jellyfin/web/config.json', '/usr/share/jellyfin/web/index.html',
)))
def sha256(path):
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def hashes():
# Include missing paths so an unexpected new file is also detected.
return {str(p): sha256(p) if p.exists() else None for p in PROTECTED}
def read_manifest(directory):
value = json.loads((directory / 'meta.json').read_text(encoding='utf-8-sig'))
if not isinstance(value, dict):
raise ValueError('Plugin manifest must be an object')
return value
def validate_manifest(metadata, version=None):
if metadata.get('name') != 'Enigma2' or str(uuid.UUID(metadata.get('guid', ''))) != GUID:
raise ValueError('Unexpected plugin identity')
if metadata.get('targetAbi') != ABI:
raise ValueError('Unexpected plugin target ABI')
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d+', str(metadata.get('version', ''))):
raise ValueError('Invalid plugin version')
if metadata.get('status') != 'Active':
raise ValueError('Existing plugin is not Active')
if version is not None and metadata['version'] != version:
raise ValueError('Installed manifest version mismatch')
def existing_plugin(plugins):
matches = []
for directory in plugins.iterdir():
if not directory.is_dir():
continue
metadata = {}
if (directory / 'meta.json').is_file():
try:
metadata = read_manifest(directory)
except (ValueError, OSError):
pass
if (directory.name.startswith('Enigma2_') or (directory / DLL).exists()
or metadata.get('guid') == GUID or metadata.get('name') == 'Enigma2'):
matches.append(directory)
if len(matches) != 1:
raise ValueError('Expected exactly one existing Enigma2 installation; resolve conflicts first')
old = matches[0]
if old.is_symlink() or any(p.is_symlink() for p in old.rglob('*')):
raise ValueError('Symlinks in the plugin installation are unsupported')
metadata = read_manifest(old)
validate_manifest(metadata)
if old.name != 'Enigma2_' + metadata['version'] or not (old / DLL).is_file():
raise ValueError('Plugin directory, manifest and DLL are inconsistent')
return old, metadata
def command(arguments):
# Never print raw service logs, which can contain unrelated sensitive data.
return subprocess.run(arguments, check=True, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True).stdout
def service(action):
command(['systemctl', action, 'jellyfin'])
def log_boundary(directory):
offsets = {}
for path in directory.glob('jellyfin*.log'):
if path.is_file() and not path.is_symlink():
stat = path.stat()
offsets[(stat.st_dev, stat.st_ino)] = stat.st_size
if not offsets:
raise RuntimeError('Cannot establish a fresh Jellyfin file-log boundary')
return offsets
def fresh_plugin_load(directory, boundary, version):
for path in directory.glob('jellyfin*.log'):
if not path.is_file() or path.is_symlink():
continue
try:
with path.open('rb') as stream:
stat = os.fstat(stream.fileno())
start = boundary.get((stat.st_dev, stat.st_ino), 0)
if start > stat.st_size: # Existing log was truncated at restart.
start = 0
stream.seek(start)
remaining = stat.st_size - start
carry = ''
while remaining > 0:
chunk = stream.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
text = carry + chunk.decode('utf-8', errors='replace')
if loaded_version(text, version):
return True
carry = text[-256:]
except FileNotFoundError: # Rotation between listing and opening; retry next poll.
continue
return False
def loaded_version(log, version):
# Match only the loader's exact plugin name/version; do not return log content.
return re.search(r'Loaded plugin:\s*"?Enigma2"?\s+"?' + re.escape(version)
+ r'"?(?=\s|$)', log, flags=re.MULTILINE) is not None
def wait_ready(directory, version, expected_hash, protected_before, boundary, timeout=60):
deadline = time.monotonic() + timeout
observed_version = '<unavailable>'
while time.monotonic() < deadline:
if hashes() != protected_before:
raise RuntimeError('Protected Jellyfin configuration, wrapper or web file changed')
validate_manifest(read_manifest(directory), version)
if sha256(directory / DLL) != expected_hash:
raise RuntimeError('Installed DLL checksum mismatch')
try:
with urllib.request.urlopen('http://127.0.0.1:8096/System/Info/Public', timeout=3) as response:
info = json.load(response)
actual_version = info.get('Version') if isinstance(info, dict) else None
# The public endpoint may return incomplete startup data before it is ready.
# Report only a short numeric version, never arbitrary response content.
if actual_version is None:
observed_version = '<missing>'
elif isinstance(actual_version, str) and re.fullmatch(r'[0-9]{1,5}(?:\.[0-9]{1,5}){1,3}', actual_version):
observed_version = actual_version
else:
observed_version = '<invalid>'
if actual_version == '12.2.0' and fresh_plugin_load(LOGS, boundary, version):
return
except (urllib.error.URLError, TimeoutError, ConnectionError, json.JSONDecodeError):
pass
time.sleep(1)
raise RuntimeError('Public health and fresh plugin load confirmation did not pass; '
+ 'observedVersion=' + observed_version)
def activate(old, new, stage, backup, verify, verify_rollback, control=service):
"""Keep the original plugin until the replacement passes; restore on any failure."""
saved = backup / old.name
moved_old = False
installed = False
try:
control('stop')
old.rename(saved)
moved_old = True
stage.rename(new)
installed = True
control('start')
verify()
except BaseException as error:
rollback_errors = []
# Even a failed start may leave a running process; stop before restoring files.
try:
control('stop')
except Exception:
rollback_errors.append('stop')
# Avoid modifying files while Jellyfin may still be running.
if not rollback_errors:
try:
if installed:
shutil.rmtree(new)
if moved_old:
saved.rename(old)
# Rename preserves original ownership; retain a content backup as well.
shutil.copytree(old, saved, copy_function=shutil.copy2)
except Exception:
rollback_errors.append('restore')
try:
control('start')
if not rollback_errors:
verify_rollback()
except Exception:
rollback_errors.append('start_or_verification')
if rollback_errors:
raise RuntimeError('Deployment failed; rollback requires attention: '
+ ', '.join(rollback_errors) + '; backup: ' + str(backup)) from error
raise RuntimeError('Deployment failed; previous plugin restored and started; backup: '
+ str(backup)) from error
finally:
if stage.exists():
shutil.rmtree(stage)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--maintenance-approved', action='store_true',
help='Required confirmation of an already agreed Jellyfin restart window')
parser.add_argument('--dll', type=pathlib.Path, required=True)
parser.add_argument('--expected-sha256', required=True)
args = parser.parse_args(argv)
if not args.maintenance_approved:
parser.error('--maintenance-approved is required; this script cannot inspect playback sessions')
expected = args.expected_sha256.lower()
if not re.fullmatch(r'[0-9a-f]{64}', expected):
parser.error('--expected-sha256 must contain exactly 64 hexadecimal digits')
if not args.dll.is_file() or sha256(args.dll) != expected:
raise ValueError('Input DLL is missing or does not match the supplied checksum')
old, metadata = existing_plugin(PLUGINS)
new = PLUGINS / ('Enigma2_' + VERSION)
if new.exists() or new.is_symlink():
raise ValueError('Target plugin directory already exists; refusing to replace it')
before = hashes()
old_hash = sha256(old / DLL)
boundary = log_boundary(LOGS)
stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
backup = BACKUPS / ('plugin-only-' + stamp)
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
stage = backup / 'candidate'
try:
shutil.copytree(old, stage, copy_function=shutil.copy2)
shutil.copy2(args.dll, stage / DLL)
updated = dict(metadata, version=VERSION, status='Active', targetAbi=ABI)
(stage / 'meta.json').write_text(json.dumps(updated, indent=2) + '\n', encoding='utf-8')
for path in [stage, *stage.rglob('*')]:
shutil.chown(path, user='jellyfin', group='jellyfin')
if sha256(stage / DLL) != expected or hashes() != before:
raise RuntimeError('Pre-restart checksum verification failed')
activate(old, new, stage, backup,
lambda: wait_ready(new, VERSION, expected, before, boundary),
lambda: wait_ready(old, metadata['version'], old_hash, before, boundary))
finally:
if stage.exists():
shutil.rmtree(stage)
print(json.dumps({'plugin': VERSION, 'backup': str(backup), 'ready': True,
'sha256': expected, 'fresh_plugin_load_confirmed': True,
'server_configuration_and_wrappers_unchanged': True}))
if __name__ == '__main__':
main()