Align decoder startup deadlines and deploy resilient 7.0.8
This commit is contained in:
+253
-49
@@ -1,62 +1,266 @@
|
||||
"""Install only the plugin DLL/manifest; --maintenance-approved permits the agreed restart."""
|
||||
"""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 sqlite3
|
||||
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()
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--maintenance-approved', action='store_true')
|
||||
args = parser.parse_args()
|
||||
key = sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro', uri=True).execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0]
|
||||
headers = {'Authorization': 'MediaBrowser Token="' + key + '", Client="PluginDeployment", Device="Server", DeviceId="plugin-deployment", Version="7"'}
|
||||
sessions = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Sessions', headers=headers)))
|
||||
if any(s.get('NowPlayingItem') for s in sessions) and not args.maintenance_approved:
|
||||
raise SystemExit('Active playback: maintenance approval required')
|
||||
protected = ['/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 hashes():
|
||||
return {p: hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() for p in protected if pathlib.Path(p).exists()}
|
||||
before = hashes()
|
||||
plugins = pathlib.Path('/var/lib/jellyfin/plugins')
|
||||
old = [p for p in plugins.glob('Enigma2_*') if p.is_dir()]
|
||||
backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('plugin-only-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
|
||||
backup.mkdir(mode=0o700)
|
||||
new = plugins / 'Enigma2_7.0.8.0'
|
||||
metadata = json.loads((old[0] / 'meta.json').read_text())
|
||||
metadata.update(version='7.0.8.0', status='Active', targetAbi='12.2.0.0')
|
||||
subprocess.run(['systemctl', 'stop', 'jellyfin'], check=True)
|
||||
try:
|
||||
for p in old:
|
||||
shutil.move(str(p), str(backup / p.name))
|
||||
new.mkdir()
|
||||
shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll', new / 'Jellyfin.Plugin.Enigma2.dll')
|
||||
(new / 'meta.json').write_text(json.dumps(metadata))
|
||||
subprocess.run(['chown', '-R', 'jellyfin:jellyfin', str(new)], check=True)
|
||||
except Exception:
|
||||
if new.exists():
|
||||
shutil.rmtree(new)
|
||||
for p in old:
|
||||
if (backup / p.name).exists():
|
||||
shutil.move(str(backup / p.name), str(p))
|
||||
raise
|
||||
finally:
|
||||
subprocess.run(['systemctl', 'start', 'jellyfin'], check=True)
|
||||
assert hashes() == before, 'Unexpected change outside plugin installation'
|
||||
deadline = time.monotonic() + 45
|
||||
while True:
|
||||
# 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:
|
||||
installed = json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Plugins', headers=headers), timeout=3))
|
||||
if any(p['Name'] == 'Enigma2' and p['Version'] == '7.0.8.0' and p['Status'] == 'Active' for p in installed):
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError('Jellyfin did not load the deployed plugin within 45 seconds; backup: ' + str(backup))
|
||||
time.sleep(1)
|
||||
print(json.dumps({'plugin': '7.0.8.0', 'backup': str(backup), 'ready': True, 'server_configuration_and_wrappers_unchanged': True}))
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user