feat: add configuration-preserving CUPS project updater
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
- 驱动管理器账号密码可通过脚本菜单修改
|
- 驱动管理器账号密码可通过脚本菜单修改
|
||||||
- 网络和打印服务守护(断网自动切 DHCP,CUPS 卡死自动恢复)
|
- 网络和打印服务守护(断网自动切 DHCP,CUPS 卡死自动恢复)
|
||||||
- 静态 IP 支持网关变更自愈:先切 DHCP,再按原静态 IP 尾号迁移到新网段
|
- 静态 IP 支持网关变更自愈:先切 DHCP,再按原静态 IP 尾号迁移到新网段
|
||||||
|
- 独立程序更新(保留配置和密码,更新前备份,失败自动回滚)
|
||||||
- 支持一键卸载
|
- 支持一键卸载
|
||||||
|
|
||||||
## 快速安装
|
## 快速安装
|
||||||
@@ -149,7 +150,8 @@ lpinfo -v | grep usb
|
|||||||
## 文件说明
|
## 文件说明
|
||||||
|
|
||||||
```
|
```
|
||||||
├── setup_cups.sh # 一键安装/卸载脚本
|
├── setup_cups.sh # 安装/卸载/更新菜单
|
||||||
|
├── update_cups.py # 独立更新器(Python 3.9+)
|
||||||
├── watchdog/ # 网络和打印服务守护脚本
|
├── watchdog/ # 网络和打印服务守护脚本
|
||||||
├── cups-templates-zh_CN/ # 中文界面模板(65个文件)
|
├── cups-templates-zh_CN/ # 中文界面模板(65个文件)
|
||||||
├── cups-docroot-zh_CN/ # CUPS 中文首页和前端汉化脚本
|
├── cups-docroot-zh_CN/ # CUPS 中文首页和前端汉化脚本
|
||||||
@@ -191,20 +193,38 @@ python3 -B -m unittest discover -s tests -v
|
|||||||
|
|
||||||
测试会模拟系统命令和驱动安装,不会修改真实网络、安装驱动或重启服务。
|
测试会模拟系统命令和驱动安装,不会修改真实网络、安装驱动或重启服务。
|
||||||
覆盖守护锁释放与连续失败恢复、多网卡地址选择、APT 等待/超时、PDF 备用驱动、DEB 架构筛选和基础访问控制。
|
覆盖守护锁释放与连续失败恢复、多网卡地址选择、APT 等待/超时、PDF 备用驱动、DEB 架构筛选和基础访问控制。
|
||||||
通过这些测试不等于通过实机验证;USB 打印、AirPrint、NetworkManager/netplan 切换仍需在目标设备验证。
|
更新器测试使用临时安装目录和模拟的 systemctl,覆盖预览、取消、配置保留、重复更新、服务状态恢复、写入失败及中断回滚。
|
||||||
|
通过这些测试不等于通过实机验证;systemd 更新流程、USB 打印、AirPrint、NetworkManager/netplan 切换仍需在目标设备验证。
|
||||||
|
|
||||||
## 已部署设备升级提示
|
## 更新已部署设备
|
||||||
|
|
||||||
更新 Git 仓库不会自动更新 `/opt` 下正在运行的文件。已安装设备需要部署更新后的
|
更新 Git 仓库不会自动更新设备上的程序。请在打印服务器的 **root 终端** 执行以下命令,
|
||||||
`watchdog/network-watchdog.sh`、`watchdog/print-watchdog.sh` 到 `/opt/cups-watchdog/`,
|
或下载最新版 `setup_cups.sh` 后选择菜单 **8:更新已安装程序**。旧版安装脚本没有更新入口,需要先获取新版。
|
||||||
以及 `cups-driver-manager/driver_manager.py` 到 `/opt/cups-driver-manager/`,保留原有配置和账号密码。
|
|
||||||
|
|
||||||
旧版守护脚本可能在 `/run/cups-watchdog/` 留下 `network.lock`、`print.lock` 目录。
|
```bash
|
||||||
部署新文件后重启设备一次,可清除 `/run` 中的旧锁并加载新代码。
|
# 下载最新版脚本并进入更新流程(显示文件清单后需要输入 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 安装问题,添加隔离回归测试。
|
- 2026-09-11: 修复守护锁、多网卡静态 IP、APT 等待、PDF 备用驱动和多架构 DEB 安装问题,添加隔离回归测试。
|
||||||
- 2024-12-01: 添加卸载功能、优化打印速度
|
- 2024-12-01: 添加卸载功能、优化打印速度
|
||||||
- 2024-11-30: 修复 CSS 兼容 CUPS 2.4.7、添加 PDF 打印机选项
|
- 2024-11-30: 修复 CSS 兼容 CUPS 2.4.7、添加 PDF 打印机选项
|
||||||
|
|||||||
+30
-1
@@ -6,6 +6,7 @@
|
|||||||
# 用法:
|
# 用法:
|
||||||
# 安装: chmod +x setup_cups.sh && ./setup_cups.sh
|
# 安装: chmod +x setup_cups.sh && ./setup_cups.sh
|
||||||
# 卸载: ./setup_cups.sh --uninstall
|
# 卸载: ./setup_cups.sh --uninstall
|
||||||
|
# 更新: ./setup_cups.sh --update
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -55,6 +56,24 @@ download_repo_archive() {
|
|||||||
curl -fsSL -o "$output_file" "$REPO_ARCHIVE_FALLBACK_URL" 2>/dev/null
|
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用户
|
# 检查是否为root用户
|
||||||
check_root() {
|
check_root() {
|
||||||
if [ "$(id -u)" != "0" ]; then
|
if [ "$(id -u)" != "0" ]; then
|
||||||
@@ -2034,6 +2053,8 @@ show_help() {
|
|||||||
echo " $0 显示主菜单"
|
echo " $0 显示主菜单"
|
||||||
echo " $0 --install 直接安装 CUPS 打印服务"
|
echo " $0 --install 直接安装 CUPS 打印服务"
|
||||||
echo " $0 --uninstall 直接卸载 CUPS 打印服务"
|
echo " $0 --uninstall 直接卸载 CUPS 打印服务"
|
||||||
|
echo " $0 --update 更新已安装的程序,保留配置和密码"
|
||||||
|
echo " 可追加 --dry-run(仅检查)或 --yes(确认更新)"
|
||||||
echo " $0 --driver-manager-auth"
|
echo " $0 --driver-manager-auth"
|
||||||
echo " 修改驱动管理器用户名和密码"
|
echo " 修改驱动管理器用户名和密码"
|
||||||
echo " $0 --help 显示此帮助信息"
|
echo " $0 --help 显示此帮助信息"
|
||||||
@@ -2147,9 +2168,10 @@ show_menu() {
|
|||||||
echo " 5) 查看当前网络状态(静态/DHCP)"
|
echo " 5) 查看当前网络状态(静态/DHCP)"
|
||||||
echo " 6) 一键共享所有打印机"
|
echo " 6) 一键共享所有打印机"
|
||||||
echo " 7) 修改驱动管理器用户名和密码"
|
echo " 7) 修改驱动管理器用户名和密码"
|
||||||
|
echo " 8) 更新已安装程序(保留配置和密码)"
|
||||||
echo " 0) 退出"
|
echo " 0) 退出"
|
||||||
echo ""
|
echo ""
|
||||||
read -p " 请输入选项 [0-7]: " choice < /dev/tty
|
read -p " 请输入选项 [0-8]: " choice < /dev/tty
|
||||||
|
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
1)
|
1)
|
||||||
@@ -2192,6 +2214,9 @@ show_menu() {
|
|||||||
read -p "按 Enter 返回主菜单..." < /dev/tty
|
read -p "按 Enter 返回主菜单..." < /dev/tty
|
||||||
show_menu
|
show_menu
|
||||||
;;
|
;;
|
||||||
|
8)
|
||||||
|
update_cups
|
||||||
|
;;
|
||||||
0)
|
0)
|
||||||
echo "已退出"
|
echo "已退出"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -2212,6 +2237,10 @@ case "${1:-}" in
|
|||||||
--install|-i)
|
--install|-i)
|
||||||
main
|
main
|
||||||
;;
|
;;
|
||||||
|
--update)
|
||||||
|
shift
|
||||||
|
update_cups "$@"
|
||||||
|
;;
|
||||||
--driver-manager-auth|--dm-auth)
|
--driver-manager-auth|--dm-auth)
|
||||||
change_driver_manager_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