feat: add configuration-preserving CUPS project updater

This commit is contained in:
Codex
2026-09-11 14:04:12 +08:00
parent 5f49605d24
commit 5f7e79b089
4 changed files with 895 additions and 10 deletions
+381
View File
@@ -0,0 +1,381 @@
"""Updater transaction and service tests; all targets live in temporary roots."""
import importlib.util
from contextlib import ExitStack, nullcontext
import io
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import unittest
from unittest.mock import patch
import zipfile
from test_regressions import IsolatedTest, ROOT, bash_path, function
spec = importlib.util.spec_from_file_location('cups_project_updater', ROOT / 'update_cups.py')
updater = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = updater
spec.loader.exec_module(updater)
class FakeServices:
def __init__(self, fail_restore=False, fail_pause=False):
self.calls = []
self.fail_restore = fail_restore
self.fail_pause = fail_pause
def snapshot(self, manager_changed):
self.calls.append(('snapshot', manager_changed))
def pause(self):
self.calls.append('pause')
if self.fail_pause:
raise updater.UpdateError('simulated busy service')
def restore(self):
self.calls.append('restore')
if self.fail_restore:
self.fail_restore = False
raise updater.UpdateError('simulated startup failure')
def stop_for_rollback(self):
self.calls.append('stop_for_rollback')
class UpdateTests(IsolatedTest):
def setUp(self):
super().setUp()
self.source = self.directory / 'release'
self.system = self.directory / 'system'
self.source.mkdir()
self.system.mkdir()
self.write(self.source, 'setup_cups.sh', b'#!/bin/bash\necho installer\n')
for source, target in updater.PROGRAMS.items():
new = b'version = 2\n' if source.endswith('.py') else b'#!/bin/bash\necho new\n'
old = b'version = 1\n' if source.endswith('.py') else b'#!/bin/bash\necho old\n'
self.write(self.source, source, new)
self.write(self.system, target, old)
self.write(self.source, 'cups-templates-zh_CN/header.tmpl', b'<div class="header"><div class="body">new')
self.write(self.source, 'cups-templates-zh_CN/trailer.tmpl', b'<div class="footer">new')
self.write(self.source, 'cups-docroot-zh_CN/index.html', b'new homepage')
for path in ('usr/share/cups/templates/header.tmpl', 'usr/share/cups/templates-zh_CN/header.tmpl',
'usr/share/cups/templates/trailer.tmpl', 'usr/share/cups/doc-root/index.html'):
self.write(self.system, path, b'old template')
self.write(self.system, 'usr/share/cups/doc-root/cups.css', b'.cups-header {}')
self.protected = [
'etc/cups/cupsd.conf', 'etc/cups/printers.conf', 'etc/network/interfaces',
'etc/cups-watchdog/network-watchdog.conf', 'etc/cups-watchdog/print-watchdog.conf',
'etc/systemd/system/cups-driver-manager.service',
'etc/systemd/system/cups-driver-manager.service.d/auth.conf',
'opt/cups-driver-manager/.username', 'opt/cups-driver-manager/.password',
]
for relative in self.protected:
self.write(self.system, relative, b'preserve this fixture exactly')
@staticmethod
def write(root, relative, content):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return path
def plan(self):
return updater.build_plan(self.source, self.system, bash=bash_path())
def apply(self, changes, services):
with patch('sys.stdout', new=io.StringIO()):
return updater.apply_update(changes, self.system, services, {'source': 'test fixture'})
def assert_originals(self, changes):
for change in changes:
self.assertEqual((self.system / change.relative).read_bytes(), change.previous)
def test_success_preserves_config_credentials_and_makes_verified_backup(self):
changes = self.plan()
services = FakeServices()
for kind in ('network', 'print'):
(self.system / f'run/cups-watchdog/{kind}.lock').mkdir(parents=True)
backup = self.apply(changes, services)
for change in changes:
target = self.system / change.relative
self.assertEqual(target.read_bytes(), change.content)
self.assertEqual(stat.S_IMODE(target.stat().st_mode), stat.S_IMODE(change.metadata.st_mode))
self.assertEqual((backup / 'files' / change.relative).read_bytes(), change.previous)
for relative in self.protected:
self.assertEqual((self.system / relative).read_bytes(), b'preserve this fixture exactly')
self.assertFalse((backup / 'files' / relative).exists())
self.assertEqual(json.loads((backup / 'manifest.json').read_text())['status'], 'complete')
self.assertEqual(services.calls, [('snapshot', True), 'pause', 'restore'])
self.assertFalse((self.system / 'run/cups-watchdog/network.lock').exists())
self.assertEqual(self.plan(), [])
def test_new_files_and_optional_components_are_not_installed(self):
(self.system / updater.PROGRAMS['cups-driver-manager/driver_manager.py']).unlink()
self.write(self.source, 'cups-docroot-zh_CN/new.js', b'new feature')
changes = self.plan()
self.assertFalse(any(change.relative.startswith('opt/cups-driver-manager/') for change in changes))
self.assertFalse(any(change.relative.endswith('new.js') for change in changes))
def test_localized_template_matches_installed_css(self):
header = next(change for change in self.plan() if change.relative == 'usr/share/cups/templates/header.tmpl')
self.assertIn(b'class="cups-header"', header.content)
self.assertIn(b'class="cups-body"', header.content)
def test_start_failure_rolls_back_all_files_and_restores_services(self):
changes = self.plan()
services = FakeServices(fail_restore=True)
with self.assertRaisesRegex(updater.UpdateError, 'Original files/service states restored'):
self.apply(changes, services)
self.assert_originals(changes)
self.assertEqual(services.calls[-2:], ['stop_for_rollback', 'restore'])
manifest = next((self.system / 'var/backups/cups-project').glob('*/manifest.json'))
self.assertEqual(json.loads(manifest.read_text())['status'], 'rolled_back')
def test_partial_write_failure_rolls_back_previous_replacements(self):
changes = self.plan()
real_write = updater.atomic_write
calls = 0
def fail_once(*args):
nonlocal calls
calls += 1
if calls == 2:
raise OSError('simulated disk write failure')
real_write(*args)
with patch.object(updater, 'atomic_write', side_effect=fail_once), self.assertRaises(updater.UpdateError):
self.apply(changes, FakeServices())
self.assert_originals(changes)
def test_interrupt_during_write_rolls_back(self):
changes = self.plan()
real_write = updater.atomic_write
calls = 0
def interrupt_once(*args):
nonlocal calls
calls += 1
real_write(*args)
if calls == 2:
raise KeyboardInterrupt()
with patch.object(updater, 'atomic_write', side_effect=interrupt_once), self.assertRaises(updater.UpdateError):
self.apply(changes, FakeServices())
self.assert_originals(changes)
def test_busy_service_aborts_before_replacing_files(self):
changes = self.plan()
services = FakeServices(fail_pause=True)
with self.assertRaises(updater.UpdateError):
self.apply(changes, services)
self.assert_originals(changes)
self.assertEqual(services.calls, [('snapshot', True), 'pause', 'restore'])
def test_nonempty_legacy_lock_is_not_deleted(self):
changes = self.plan()
lock_file = self.write(self.system, 'run/cups-watchdog/network.lock/owner', b'unknown lock owner')
with self.assertRaises(updater.UpdateError):
self.apply(changes, FakeServices())
self.assert_originals(changes)
self.assertTrue(lock_file.is_file())
def test_modified_file_aborts_before_service_changes(self):
changes = self.plan()
self.write(self.system, changes[0].relative, b'concurrent edit')
services = FakeServices()
with self.assertRaisesRegex(updater.UpdateError, 'changed during backup'):
self.apply(changes, services)
self.assertEqual(services.calls, [])
self.assertEqual((self.system / changes[0].relative).read_bytes(), b'concurrent edit')
def test_invalid_release_is_rejected_before_apply(self):
self.write(self.source, 'watchdog/network-watchdog.sh', b'if then\n')
with self.assertRaises(updater.UpdateError):
self.plan()
self.assertFalse((self.system / 'var/backups').exists())
def test_incomplete_release_is_rejected(self):
(self.source / 'cups-driver-manager/driver_manager.py').unlink()
with self.assertRaisesRegex(updater.UpdateError, 'Incomplete release'):
self.plan()
def test_vanilla_cups_is_not_treated_as_a_project_install(self):
blank = self.directory / 'vanilla'
self.write(blank, 'usr/share/cups/doc-root/index.html', b'default CUPS')
with self.assertRaisesRegex(updater.UpdateError, 'No existing installation'):
updater.build_plan(self.source, blank, bash=bash_path())
def test_rollback_failure_is_explicit_and_backup_is_retained(self):
changes = self.plan()
real_write = updater.atomic_write
calls = 0
def fail_rollback(*args):
nonlocal calls
calls += 1
if calls > len(changes):
raise OSError('simulated rollback failure')
real_write(*args)
with patch.object(updater, 'atomic_write', side_effect=fail_rollback), self.assertRaisesRegex(updater.UpdateError, 'rollback failure'):
self.apply(changes, FakeServices(fail_restore=True))
manifest = next((self.system / 'var/backups/cups-project').glob('*/manifest.json'))
self.assertEqual(json.loads(manifest.read_text())['status'], 'rollback_failed')
self.assertTrue((manifest.parent / 'files' / changes[0].relative).is_file())
def archive(self, entries):
archive = self.directory / 'release.zip'
with zipfile.ZipFile(archive, 'w') as handle:
for name, data in entries:
handle.writestr(name, data)
return archive
def test_archive_traversal_is_rejected(self):
archive = self.archive([('../escape', b'x')])
with self.assertRaises(updater.UpdateError):
updater.extract_archive(archive, self.directory / 'extracted')
self.assertFalse((self.directory / 'escape').exists())
def test_archive_symlink_is_rejected(self):
entry = zipfile.ZipInfo('release/link')
entry.create_system = 3
entry.external_attr = (stat.S_IFLNK | 0o777) << 16
archive = self.archive([(entry, b'/etc/cups')])
with self.assertRaises(updater.UpdateError):
updater.extract_archive(archive, self.directory / 'extracted')
def test_archive_root_is_discovered(self):
archive = self.archive([('project/setup_cups.sh', b'#!/bin/bash\n')])
root = updater.extract_archive(archive, self.directory / 'extracted')
self.assertEqual(root.name, 'project')
def test_plain_http_download_is_rejected(self):
with self.assertRaises(updater.UpdateError), patch.object(updater.urllib.request, 'urlopen') as request:
updater.fetch_archive('http://example.invalid/release.zip', self.directory / 'download.zip')
request.assert_not_called()
class MainTests(IsolatedTest):
def setUp(self):
super().setUp()
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(patch.object(updater, 'check_prerequisites'))
stack.enter_context(patch.object(updater, 'update_lock', return_value=nullcontext()))
stack.enter_context(patch.object(updater, 'safe_path', return_value=self.directory / 'lock'))
stack.enter_context(patch.object(updater.tempfile, 'TemporaryDirectory', return_value=nullcontext(str(self.directory))))
stack.enter_context(patch('sys.stdout', new=io.StringIO()))
self.plan = stack.enter_context(patch.object(updater, 'build_plan', return_value=[
updater.Change('opt/cups-watchdog/network-watchdog.sh', b'new', b'old', None),
]))
self.apply = stack.enter_context(patch.object(updater, 'apply_update'))
self.confirm = stack.enter_context(patch.object(updater, 'confirm', return_value=False))
self.fetch = stack.enter_context(patch.object(updater, 'fetch_archive'))
def run_main(self, *args):
return updater.main(['--source-dir', str(self.directory), *args])
def test_dry_run_never_changes_files_or_services(self):
self.assertEqual(self.run_main('--dry-run', '--yes'), 0)
self.apply.assert_not_called()
self.confirm.assert_not_called()
self.fetch.assert_not_called()
def test_cancel_never_applies_the_plan(self):
self.assertEqual(self.run_main(), 0)
self.confirm.assert_called_once()
self.apply.assert_not_called()
def test_no_changes_never_prompts_or_applies(self):
self.plan.return_value = []
self.assertEqual(self.run_main(), 0)
self.confirm.assert_not_called()
self.apply.assert_not_called()
def test_yes_applies_without_a_terminal(self):
self.assertEqual(self.run_main('--yes'), 0)
self.confirm.assert_not_called()
self.apply.assert_called_once()
class ServiceTests(unittest.TestCase):
def setUp(self):
self.calls = []
self.units = {
updater.TIMERS[0]: ['loaded', 'active', 'enabled'],
updater.TIMERS[1]: ['loaded', 'inactive', 'disabled'],
updater.WATCHDOGS[0]: ['loaded', 'inactive', 'static'],
updater.WATCHDOGS[1]: ['loaded', 'inactive', 'static'],
updater.MANAGER: ['loaded', 'active', 'enabled'],
}
self.services = updater.Services(runner=self.run_command, sleeper=lambda _: None)
self.busy = patch.object(self.services, 'busy_processes', return_value=([], set()))
self.busy.start()
self.addCleanup(self.busy.stop)
def run_command(self, cmd, **kwargs):
self.calls.append(cmd)
operation, unit = cmd[1:3]
state = self.units.get(unit, ['not-found', 'inactive', ''])
if operation == 'show':
output = f'LoadState={state[0]}\nActiveState={state[1]}\nUnitFileState={state[2]}\nMainPID=123\n'
return subprocess.CompletedProcess(cmd, 1 if state[0] == 'not-found' else 0, output, '')
state[1] = 'inactive' if operation == 'stop' else 'active'
return subprocess.CompletedProcess(cmd, 0, '', '')
def test_only_previously_active_units_are_restored(self):
self.services.snapshot(True)
self.services.pause()
self.services.restore()
actions = [(cmd[1], cmd[2]) for cmd in self.calls if cmd[1] != 'show']
self.assertEqual(actions, [('stop', updater.TIMERS[0]), ('stop', updater.MANAGER),
('start', updater.MANAGER), ('start', updater.TIMERS[0])])
self.assertNotIn('cups.service', str(self.calls))
self.assertNotIn('enable', str(actions))
def test_missing_optional_units_are_supported(self):
self.units.clear()
self.services.snapshot(True)
self.services.pause()
self.services.restore()
self.assertFalse(any(cmd[1] != 'show' for cmd in self.calls))
def test_active_watchdog_is_not_killed(self):
self.units[updater.WATCHDOGS[0]][1] = 'activating'
self.services.snapshot(True)
with self.assertRaisesRegex(updater.UpdateError, 'still running'):
self.services.pause()
self.services.restore()
self.assertNotIn(['systemctl', 'stop', updater.WATCHDOGS[0]], self.calls)
self.assertEqual(self.units[updater.TIMERS[0]][1], 'active')
def test_driver_installation_blocks_manager_stop(self):
self.services.snapshot(True)
with patch.object(self.services, 'busy_processes', return_value=([], {124})), self.assertRaisesRegex(updater.UpdateError, 'installation'):
self.services.pause()
self.assertNotIn(['systemctl', 'stop', updater.MANAGER], self.calls)
class LauncherTests(unittest.TestCase):
def test_update_dispatch_does_not_call_the_installer(self):
source = (ROOT / 'setup_cups.sh').read_text(encoding='utf-8')
dispatch = source.rsplit('case "${1:-}" in', 1)[1]
script = 'set -- --update --dry-run\nupdate_cups() { echo "UPDATE:$*"; }; main() { echo UNEXPECTED_INSTALL; };\n'
result = subprocess.run([bash_path(), '-s'], input=script + 'case "${1:-}" in' + dispatch,
capture_output=True, text=True, encoding='utf-8', timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), 'UPDATE:--dry-run')
def test_update_launcher_has_no_install_configuration_calls(self):
source = (ROOT / 'setup_cups.sh').read_text(encoding='utf-8')
body = function(source, 'update_cups')
for command in ('configure_cups', 'configure_static_ip', 'install_driver_manager', 'apt install'):
self.assertNotIn(command, body)
if __name__ == '__main__':
unittest.main()