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
+29 -9
View File
@@ -14,6 +14,7 @@
- 驱动管理器账号密码可通过脚本菜单修改
- 网络和打印服务守护(断网自动切 DHCP,CUPS 卡死自动恢复)
- 静态 IP 支持网关变更自愈:先切 DHCP,再按原静态 IP 尾号迁移到新网段
- 独立程序更新(保留配置和密码,更新前备份,失败自动回滚)
- 支持一键卸载
## 快速安装
@@ -149,7 +150,8 @@ lpinfo -v | grep usb
## 文件说明
```
├── setup_cups.sh # 一键安装/卸载脚本
├── setup_cups.sh # 安装/卸载/更新菜单
├── update_cups.py # 独立更新器(Python 3.9+
├── watchdog/ # 网络和打印服务守护脚本
├── cups-templates-zh_CN/ # 中文界面模板(65个文件)
├── cups-docroot-zh_CN/ # CUPS 中文首页和前端汉化脚本
@@ -191,20 +193,38 @@ python3 -B -m unittest discover -s tests -v
测试会模拟系统命令和驱动安装,不会修改真实网络、安装驱动或重启服务。
覆盖守护锁释放与连续失败恢复、多网卡地址选择、APT 等待/超时、PDF 备用驱动、DEB 架构筛选和基础访问控制。
通过这些测试不等于通过实机验证;USB 打印、AirPrint、NetworkManager/netplan 切换仍需在目标设备验证
更新器测试使用临时安装目录和模拟的 systemctl,覆盖预览、取消、配置保留、重复更新、服务状态恢复、写入失败及中断回滚
通过这些测试不等于通过实机验证;systemd 更新流程、USB 打印、AirPrint、NetworkManager/netplan 切换仍需在目标设备验证。
## 已部署设备升级提示
## 更新已部署设备
更新 Git 仓库不会自动更新 `/opt` 下正在运行的文件。已安装设备需要部署更新后的
`watchdog/network-watchdog.sh``watchdog/print-watchdog.sh``/opt/cups-watchdog/`
以及 `cups-driver-manager/driver_manager.py``/opt/cups-driver-manager/`,保留原有配置和账号密码。
更新 Git 仓库不会自动更新设备上的程序。请在打印服务器的 **root 终端** 执行以下命令,
或下载最新版 `setup_cups.sh` 后选择菜单 **8:更新已安装程序**。旧版安装脚本没有更新入口,需要先获取新版。
旧版守护脚本可能在 `/run/cups-watchdog/` 留下 `network.lock``print.lock` 目录。
部署新文件后重启设备一次,可清除 `/run` 中的旧锁并加载新代码。
不要在守护脚本仍运行时直接删除锁目录,以免破坏并发互斥。
```bash
# 下载最新版脚本并进入更新流程(显示文件清单后需要输入 y 确认)
curl -fsSL https://git.workyai.cn/237899745/S905L3A/raw/branch/master/setup_cups.sh | bash -s -- --update
# 仅检查和预览,不修改已安装文件、不停止服务
curl -fsSL https://git.workyai.cn/237899745/S905L3A/raw/branch/master/setup_cups.sh | bash -s -- --update --dry-run
# 已有最新版脚本时;非交互环境可追加 --yes,明确确认更新
bash setup_cups.sh --update --yes
```
需要 Linux、systemd、Bash 和 Python 3.9+;缺少依赖时会退出,不会自动安装软件包。
默认从 Gitea 的 `master` 下载,并检查压缩包路径和程序语法。下载失败会退出,不自动切到可能尚未同步的镜像。
可信的离线仓库副本也可使用 `python3 update_cups.py --source-dir . --dry-run` 检查,去掉 `--dry-run` 后确认更新。
- 只替换已经安装且内容有变化的驱动管理器、两个守护脚本、中文模板和首页脚本;没有变化时不会重启服务。不会补装缺失的可选组件或新增文件,也不会执行安装脚本中的配置迁移。
- 不改打印机队列、CUPS/网络/守护配置、账号密码、systemd 服务文件及开机启用状态;不升级系统软件包、不重启 CUPS。手工修改过的程序文件或中文页面会被仓库版本替换,请先看预览清单。
- 更新前在 `/var/backups/cups-project/` 保存旧程序文件、校验值及原服务状态。先暂停原本运行的守护定时器,等待正在运行的守护检查结束,再替换文件。驱动管理器代码有变化时,其 Web 页面会短暂不可用。
- 请在没有驱动上传/安装或手动守护操作时更新。检测到仍在运行的守护或驱动安装子进程会取消;不会强杀它们。确认守护空闲后,更新器会清理默认 `/run/cups-watchdog/` 中旧版遗留的空锁目录,无需为此重启设备。
- 替换或服务启动检查失败时,会尝试自动恢复旧文件和原服务运行状态;备份始终保留。断电、磁盘损坏或强制杀进程不能保证自动回滚,应根据报错及备份内的 `manifest.json` 人工恢复。
## 更新日志
- 2026-09-11: 新增菜单第 8 项及 `--update`,支持更新预览、配置保留、备份校验、失败回滚和隔离回归测试。
- 2026-09-11: 修复守护锁、多网卡静态 IP、APT 等待、PDF 备用驱动和多架构 DEB 安装问题,添加隔离回归测试。
- 2024-12-01: 添加卸载功能、优化打印速度
- 2024-11-30: 修复 CSS 兼容 CUPS 2.4.7、添加 PDF 打印机选项
+30 -1
View File
@@ -6,6 +6,7 @@
# 用法:
# 安装: chmod +x setup_cups.sh && ./setup_cups.sh
# 卸载: ./setup_cups.sh --uninstall
# 更新: ./setup_cups.sh --update
#
set -e
@@ -55,6 +56,24 @@ download_repo_archive() {
curl -fsSL -o "$output_file" "$REPO_ARCHIVE_FALLBACK_URL" 2>/dev/null
}
# 独立更新入口,不调用安装或配置函数。
update_cups() {
check_root
command -v python3 >/dev/null 2>&1 || error "更新功能需要 Python 3.9+,请先安装 Python 3"
# 在子 shell 中下载独立更新器,不调用安装流程或覆盖当前安装脚本。
(
update_dir=$(mktemp -d)
trap 'rm -f "$update_dir/update_cups.py"; rmdir "$update_dir" 2>/dev/null || true' EXIT
update_url="https://git.workyai.cn/237899745/S905L3A/raw/branch/master/update_cups.py"
info "正在下载更新器..."
if ! curl -fsSL --connect-timeout 15 --max-time 120 -o "$update_dir/update_cups.py" "$update_url" && \
! wget -q --timeout=30 --tries=2 -O "$update_dir/update_cups.py" "$update_url"; then
error "更新器下载失败,未修改已安装文件"
fi
python3 -B "$update_dir/update_cups.py" --archive-url "$REPO_ARCHIVE_URL" "$@"
)
}
# 检查是否为root用户
check_root() {
if [ "$(id -u)" != "0" ]; then
@@ -2034,6 +2053,8 @@ show_help() {
echo " $0 显示主菜单"
echo " $0 --install 直接安装 CUPS 打印服务"
echo " $0 --uninstall 直接卸载 CUPS 打印服务"
echo " $0 --update 更新已安装的程序,保留配置和密码"
echo " 可追加 --dry-run(仅检查)或 --yes(确认更新)"
echo " $0 --driver-manager-auth"
echo " 修改驱动管理器用户名和密码"
echo " $0 --help 显示此帮助信息"
@@ -2147,9 +2168,10 @@ show_menu() {
echo " 5) 查看当前网络状态(静态/DHCP)"
echo " 6) 一键共享所有打印机"
echo " 7) 修改驱动管理器用户名和密码"
echo " 8) 更新已安装程序(保留配置和密码)"
echo " 0) 退出"
echo ""
read -p " 请输入选项 [0-7]: " choice < /dev/tty
read -p " 请输入选项 [0-8]: " choice < /dev/tty
case "$choice" in
1)
@@ -2192,6 +2214,9 @@ show_menu() {
read -p "按 Enter 返回主菜单..." < /dev/tty
show_menu
;;
8)
update_cups
;;
0)
echo "已退出"
exit 0
@@ -2212,6 +2237,10 @@ case "${1:-}" in
--install|-i)
main
;;
--update)
shift
update_cups "$@"
;;
--driver-manager-auth|--dm-auth)
change_driver_manager_auth
;;
+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()
+455
View File
@@ -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)