267 lines
11 KiB
Python
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.8.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()
|