feat: add configuration-preserving CUPS project updater
This commit is contained in:
@@ -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
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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
@@ -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