feat: add configuration-preserving CUPS project updater
This commit is contained in:
+455
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update installed CUPS project files without reinstalling or changing config."""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
|
||||
ARCHIVE_URL = 'https://git.workyai.cn/237899745/S905L3A/archive/master.zip'
|
||||
MAX_DOWNLOAD = 20 * 1024 * 1024
|
||||
MAX_EXTRACTED = 100 * 1024 * 1024
|
||||
TIMERS = ('cups-network-watchdog.timer', 'cups-print-watchdog.timer')
|
||||
WATCHDOGS = ('cups-network-watchdog.service', 'cups-print-watchdog.service')
|
||||
MANAGER = 'cups-driver-manager.service'
|
||||
ACTIVE = {'active', 'activating', 'reloading', 'deactivating'}
|
||||
PROGRAMS = {
|
||||
'cups-driver-manager/driver_manager.py': 'opt/cups-driver-manager/driver_manager.py',
|
||||
'watchdog/network-watchdog.sh': 'opt/cups-watchdog/network-watchdog.sh',
|
||||
'watchdog/print-watchdog.sh': 'opt/cups-watchdog/print-watchdog.sh',
|
||||
}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def safe_path(root, relative):
|
||||
relative = PurePosixPath(relative)
|
||||
if relative.is_absolute() or '..' in relative.parts or not relative.parts:
|
||||
raise UpdateError(f'Unsafe relative path: {relative}')
|
||||
root = root.resolve()
|
||||
candidate = root
|
||||
for part in relative.parts:
|
||||
candidate = candidate / part
|
||||
if candidate.is_symlink():
|
||||
raise UpdateError(f'Refusing symbolic link: {candidate}')
|
||||
if not candidate.resolve().is_relative_to(root):
|
||||
raise UpdateError(f'Path escaped update root: {candidate}')
|
||||
return candidate
|
||||
|
||||
|
||||
def fetch_archive(url, output):
|
||||
if urllib.parse.urlsplit(url).scheme != 'https':
|
||||
raise UpdateError('The update archive URL must use HTTPS')
|
||||
started = time.monotonic()
|
||||
size = 0
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
if urllib.parse.urlsplit(response.geturl()).scheme != 'https':
|
||||
raise UpdateError('Refusing an HTTPS-to-HTTP redirect')
|
||||
with output.open('wb') as destination:
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_DOWNLOAD or time.monotonic() - started > 180:
|
||||
raise UpdateError('Update download exceeded the size/time limit')
|
||||
destination.write(chunk)
|
||||
if not size:
|
||||
raise UpdateError('Downloaded archive is empty')
|
||||
|
||||
|
||||
def extract_archive(archive_path, destination):
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
entries = archive.infolist()
|
||||
if len(entries) > 5000 or sum(entry.file_size for entry in entries) > MAX_EXTRACTED:
|
||||
raise UpdateError('Update archive is too large')
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
if '\\' in entry.filename or ':' in entry.filename:
|
||||
raise UpdateError(f'Invalid archive path: {entry.filename}')
|
||||
target = safe_path(destination, entry.filename)
|
||||
if target in seen:
|
||||
raise UpdateError(f'Duplicate archive path: {entry.filename}')
|
||||
seen.add(target)
|
||||
mode = entry.external_attr >> 16
|
||||
if stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR):
|
||||
raise UpdateError(f'Unsupported archive entry: {entry.filename}')
|
||||
archive.extractall(destination)
|
||||
candidates = [destination] + [path for path in destination.iterdir() if path.is_dir()]
|
||||
candidates = [path for path in candidates if (path / 'setup_cups.sh').is_file()]
|
||||
if len(candidates) != 1:
|
||||
raise UpdateError('Archive must contain exactly one project root')
|
||||
return candidates[0]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
relative: str
|
||||
content: bytes
|
||||
previous: bytes
|
||||
metadata: object
|
||||
|
||||
|
||||
def build_plan(source, root, bash='bash'):
|
||||
# Validate the release before inspecting or changing any installed file.
|
||||
for relative in ('setup_cups.sh', *PROGRAMS):
|
||||
path = safe_path(source, relative)
|
||||
if not path.is_file():
|
||||
raise UpdateError(f'Incomplete release: {relative}')
|
||||
if relative.endswith('.sh'):
|
||||
result = subprocess.run([bash, '-n', str(path)], capture_output=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise UpdateError(f'Invalid shell syntax: {relative}')
|
||||
else:
|
||||
ast.parse(path.read_bytes(), filename=relative)
|
||||
|
||||
if not any(safe_path(root, relative).is_file() for relative in PROGRAMS.values()) and not safe_path(root, 'usr/share/cups/templates-zh_CN').is_dir():
|
||||
raise UpdateError('No existing installation of this CUPS project was found; use the installer first')
|
||||
|
||||
mapping = dict(PROGRAMS)
|
||||
groups = (
|
||||
('cups-templates-zh_CN', 'usr/share/cups/templates', ('.tmpl',)),
|
||||
('cups-templates-zh_CN', 'usr/share/cups/templates-zh_CN', ('.tmpl',)),
|
||||
('cups-docroot-zh_CN', 'usr/share/cups/doc-root', ('.html', '.js')),
|
||||
)
|
||||
# Each source may be installed at more than one destination.
|
||||
pairs = list(mapping.items())
|
||||
for source_dir, installed_dir, suffixes in groups:
|
||||
directory = safe_path(source, source_dir)
|
||||
if not directory.is_dir():
|
||||
raise UpdateError(f'Incomplete release: {source_dir}')
|
||||
installed = safe_path(root, installed_dir)
|
||||
if installed.is_dir():
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.suffix in suffixes:
|
||||
safe_path(source, f'{source_dir}/{path.name}')
|
||||
pairs.append((f'{source_dir}/{path.name}', f'{installed_dir}/{path.name}'))
|
||||
|
||||
css = safe_path(root, 'usr/share/cups/doc-root/cups.css')
|
||||
modern_css = css.is_file() and b'.cups-header' in css.read_bytes()
|
||||
changes = []
|
||||
for origin, relative in pairs:
|
||||
target = safe_path(root, relative)
|
||||
if not target.exists():
|
||||
continue
|
||||
if not target.is_file():
|
||||
raise UpdateError(f'Installed target is not a regular file: {target}')
|
||||
content = safe_path(source, origin).read_bytes()
|
||||
if modern_css and origin in ('cups-templates-zh_CN/header.tmpl', 'cups-templates-zh_CN/trailer.tmpl'):
|
||||
for old, new in ((b'header', b'cups-header'), (b'body', b'cups-body'), (b'footer', b'cups-footer')):
|
||||
content = content.replace(b'class="' + old + b'"', b'class="' + new + b'"')
|
||||
previous = target.read_bytes()
|
||||
if previous != content:
|
||||
changes.append(Change(relative, content, previous, target.stat()))
|
||||
return changes
|
||||
|
||||
|
||||
class Services:
|
||||
def __init__(self, runner=subprocess.run, sleeper=time.sleep, proc=Path('/proc')):
|
||||
self.runner = runner
|
||||
self.sleeper = sleeper
|
||||
self.proc = proc
|
||||
self.states = {}
|
||||
self.stopped = []
|
||||
|
||||
def command(self, *args):
|
||||
result = self.runner(['systemctl', *args], capture_output=True, text=True, timeout=120)
|
||||
if result.returncode:
|
||||
raise UpdateError(f'systemctl {" ".join(args)} failed: {result.stderr.strip()}')
|
||||
return result.stdout
|
||||
|
||||
def state(self, unit):
|
||||
result = self.runner(['systemctl', 'show', unit, '--property=LoadState,ActiveState,MainPID,UnitFileState'],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if state.get('LoadState') == 'not-found':
|
||||
return state
|
||||
if result.returncode or 'ActiveState' not in state:
|
||||
raise UpdateError(f'Cannot inspect {unit}: {result.stderr.strip()}')
|
||||
return state
|
||||
|
||||
def snapshot(self, manager_changed):
|
||||
units = list(TIMERS) + list(WATCHDOGS)
|
||||
if manager_changed:
|
||||
units.append(MANAGER)
|
||||
for unit in units:
|
||||
state = self.state(unit)
|
||||
if state.get('LoadState') == 'not-found':
|
||||
continue
|
||||
if state.get('LoadState') not in ('loaded', 'masked'):
|
||||
raise UpdateError(f'Cannot inspect unit: {unit}')
|
||||
if state.get('ActiveState') in ACTIVE and state.get('UnitFileState', '').startswith('masked'):
|
||||
raise UpdateError(f'Cannot safely restore a masked active unit: {unit}')
|
||||
self.states[unit] = state
|
||||
|
||||
def busy_processes(self, manager_pid=0):
|
||||
# Read only process names/parents, never environment variables or credentials.
|
||||
parents = {}
|
||||
watchdog_pids = []
|
||||
for directory in self.proc.glob('[0-9]*'):
|
||||
try:
|
||||
pid = int(directory.name)
|
||||
fields = dict(line.split(':', 1) for line in (directory / 'status').read_text().splitlines() if ':' in line)
|
||||
parents[pid] = int(fields.get('PPid', '0').strip())
|
||||
args = (directory / 'cmdline').read_bytes().split(b'\0')
|
||||
if any(Path(os.fsdecode(arg)).name in ('network-watchdog.sh', 'print-watchdog.sh') for arg in args if arg):
|
||||
watchdog_pids.append(pid)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
descendants = {manager_pid} if manager_pid else set()
|
||||
while True:
|
||||
children = {pid for pid, parent in parents.items() if parent in descendants}
|
||||
expanded = descendants | children
|
||||
if expanded == descendants:
|
||||
break
|
||||
descendants = expanded
|
||||
return watchdog_pids, descendants - {manager_pid}
|
||||
|
||||
def pause(self):
|
||||
for timer in TIMERS:
|
||||
if self.states.get(timer, {}).get('ActiveState') in ACTIVE:
|
||||
self.stopped.append(timer)
|
||||
self.command('stop', timer)
|
||||
# Never kill a watchdog halfway through changing network configuration.
|
||||
for unit in WATCHDOGS:
|
||||
if unit not in self.states:
|
||||
continue
|
||||
for _ in range(60):
|
||||
if self.state(unit).get('ActiveState') not in ACTIVE:
|
||||
break
|
||||
self.sleeper(2)
|
||||
else:
|
||||
raise UpdateError(f'Watchdog is still running; update cancelled: {unit}')
|
||||
manager = self.states.get(MANAGER, {})
|
||||
watchdogs, children = self.busy_processes(int(manager.get('MainPID', '0')))
|
||||
if watchdogs or children:
|
||||
raise UpdateError('A manual watchdog or driver installation is running; retry when idle')
|
||||
if manager.get('ActiveState') in ACTIVE:
|
||||
self.stopped.append(MANAGER)
|
||||
self.command('stop', MANAGER)
|
||||
|
||||
def restore(self):
|
||||
# Keep watchdog timers paused until the manager has passed startup checks.
|
||||
order = ([MANAGER] if MANAGER in self.stopped else []) + [unit for unit in TIMERS if unit in self.stopped]
|
||||
errors = []
|
||||
for unit in order:
|
||||
try:
|
||||
self.command('start', unit)
|
||||
for _ in range(3):
|
||||
self.sleeper(1)
|
||||
if self.state(unit).get('ActiveState') != 'active':
|
||||
raise UpdateError(f'Startup health check failed: {unit}')
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
if unit == MANAGER:
|
||||
break
|
||||
if errors:
|
||||
raise UpdateError('; '.join(errors))
|
||||
|
||||
def stop_for_rollback(self):
|
||||
errors = []
|
||||
for unit in self.stopped:
|
||||
try:
|
||||
self.command('stop', unit)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
if errors:
|
||||
raise UpdateError('; '.join(errors))
|
||||
|
||||
|
||||
def atomic_write(target, data, metadata):
|
||||
fd, temporary = tempfile.mkstemp(prefix=f'.{target.name}.update-', dir=target.parent)
|
||||
temporary = Path(temporary)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
shutil.copystat(target, temporary)
|
||||
os.chmod(temporary, stat.S_IMODE(metadata.st_mode))
|
||||
if os.name == 'posix':
|
||||
os.chown(temporary, metadata.st_uid, metadata.st_gid)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
def clear_old_locks(root):
|
||||
for kind in ('network', 'print'):
|
||||
lock = safe_path(root, f'run/cups-watchdog/{kind}.lock')
|
||||
if lock.exists():
|
||||
if not lock.is_dir() or any(lock.iterdir()):
|
||||
raise UpdateError(f'Refusing to remove an unexpected watchdog lock: {lock}')
|
||||
lock.rmdir()
|
||||
|
||||
|
||||
def apply_update(changes, root, services, release):
|
||||
backup_root = safe_path(root, 'var/backups/cups-project')
|
||||
backup_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
backup = backup_root / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-') + uuid.uuid4().hex[:8])
|
||||
backup.mkdir(mode=0o700)
|
||||
manifest = {'release': release, 'status': 'preparing', 'files': []}
|
||||
|
||||
def save_status(status):
|
||||
manifest['status'] = status
|
||||
(backup / 'manifest.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
|
||||
for change in changes:
|
||||
original = safe_path(root, change.relative)
|
||||
copy = safe_path(backup / 'files', change.relative)
|
||||
copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(original, copy)
|
||||
if copy.read_bytes() != change.previous:
|
||||
raise UpdateError(f'File changed during backup; retry: {original}')
|
||||
manifest['files'].append({'path': change.relative, 'before': digest(change.previous), 'after': digest(change.content)})
|
||||
save_status('prepared')
|
||||
print(f'Backup: {backup}', flush=True)
|
||||
|
||||
attempted = []
|
||||
try:
|
||||
services.snapshot(any(change.relative == PROGRAMS['cups-driver-manager/driver_manager.py'] for change in changes))
|
||||
manifest['services_before'] = getattr(services, 'states', {})
|
||||
save_status('prepared')
|
||||
services.pause()
|
||||
clear_old_locks(root)
|
||||
for change in changes:
|
||||
if safe_path(root, change.relative).read_bytes() != change.previous:
|
||||
raise UpdateError(f'File changed after preflight: {change.relative}')
|
||||
save_status('applying')
|
||||
for change in changes:
|
||||
attempted.append(change)
|
||||
target = safe_path(root, change.relative)
|
||||
atomic_write(target, change.content, change.metadata)
|
||||
if target.read_bytes() != change.content:
|
||||
raise UpdateError(f'Written file verification failed: {change.relative}')
|
||||
services.restore()
|
||||
save_status('complete')
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
rollback_errors = []
|
||||
if attempted:
|
||||
try:
|
||||
services.stop_for_rollback()
|
||||
except Exception as rollback_error:
|
||||
rollback_errors.append(str(rollback_error))
|
||||
for change in reversed(attempted):
|
||||
try:
|
||||
previous = safe_path(backup / 'files', change.relative).read_bytes()
|
||||
if digest(previous) != digest(change.previous):
|
||||
raise UpdateError(f'Backup checksum mismatch: {change.relative}')
|
||||
atomic_write(safe_path(root, change.relative), previous, change.metadata)
|
||||
except Exception as rollback_error:
|
||||
rollback_errors.append(str(rollback_error))
|
||||
try:
|
||||
services.restore()
|
||||
except Exception as rollback_error:
|
||||
rollback_errors.append(str(rollback_error))
|
||||
try:
|
||||
save_status('rollback_failed' if rollback_errors else 'rolled_back')
|
||||
except Exception as rollback_error:
|
||||
rollback_errors.append(str(rollback_error))
|
||||
detail = '; '.join(rollback_errors) if rollback_errors else 'Original files/service states restored'
|
||||
raise UpdateError(f'Update failed: {error}. {detail}. Backup: {backup}') from error
|
||||
return backup
|
||||
|
||||
|
||||
@contextmanager
|
||||
def update_lock(path):
|
||||
import fcntl
|
||||
with path.open('a') as handle:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
raise UpdateError('Another update is already running') from error
|
||||
yield
|
||||
|
||||
|
||||
def confirm():
|
||||
try:
|
||||
with open('/dev/tty', 'r+') as terminal:
|
||||
terminal.write('Apply this update? [y/N]: ')
|
||||
terminal.flush()
|
||||
return terminal.readline().strip().lower() == 'y'
|
||||
except OSError as error:
|
||||
raise UpdateError('No interactive terminal. Review with --dry-run, then use --yes to confirm') from error
|
||||
|
||||
|
||||
def check_prerequisites():
|
||||
if sys.version_info < (3, 9):
|
||||
raise UpdateError('Python 3.9 or later is required')
|
||||
if os.name != 'posix' or os.geteuid() != 0:
|
||||
raise UpdateError('Run this updater as root on the Linux print server')
|
||||
if not shutil.which('systemctl') or not shutil.which('bash'):
|
||||
raise UpdateError('systemctl and Bash are required; no packages will be installed automatically')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--archive-url', default=ARCHIVE_URL, help='HTTPS archive URL (default: project master)')
|
||||
parser.add_argument('--source-dir', type=Path, help='Use a trusted local release instead of downloading')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Validate and list changes without applying them')
|
||||
parser.add_argument('--yes', action='store_true', help='Confirm the displayed file update without prompting')
|
||||
args = parser.parse_args(argv)
|
||||
check_prerequisites()
|
||||
root = Path('/')
|
||||
with update_lock(safe_path(root, 'run/cups-project-update.lock')), tempfile.TemporaryDirectory(prefix='cups-project-update-') as temporary:
|
||||
if args.source_dir:
|
||||
source = args.source_dir.resolve()
|
||||
release = {'source': str(source)}
|
||||
else:
|
||||
archive = Path(temporary) / 'release.zip'
|
||||
print('Downloading the current project release...', flush=True)
|
||||
fetch_archive(args.archive_url, archive)
|
||||
source = extract_archive(archive, Path(temporary) / 'release')
|
||||
release = {'source': args.archive_url, 'archive_sha256': digest(archive.read_bytes())}
|
||||
changes = build_plan(source, root)
|
||||
if not changes:
|
||||
print('No installed project files need updating. No services were changed.')
|
||||
return 0
|
||||
print('Only the following installed files will be replaced:')
|
||||
for change in changes:
|
||||
print(f' /{change.relative}')
|
||||
print('CUPS queues/config, network settings, credentials, systemd units and packages are preserved.')
|
||||
print('The driver-manager web service may be briefly unavailable; CUPS will not be restarted.')
|
||||
if args.dry_run or not (args.yes or confirm()):
|
||||
print('No installed files or services were changed.')
|
||||
return 0
|
||||
backup = apply_update(changes, root, Services(), release)
|
||||
print(f'Update completed. Backup retained at: {backup}')
|
||||
return 0
|
||||
|
||||
|
||||
def interrupted(signum, frame):
|
||||
raise UpdateError(f'Update interrupted by signal {signum}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
print(f'ERROR: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user