From 27ac7fb78739845bccd2b284d27684a68ba9faf6 Mon Sep 17 00:00:00 2001 From: geruipu Date: Wed, 7 Oct 2026 01:41:07 +0800 Subject: [PATCH] feat: share named volumes across Docker and rootless Podman updates [CI] --- backend/agent/README.md | 4 +- backend/probe/README.md | 4 +- .../Update/UpdateBridge.cs | 2 +- docs/deployment.md | 78 +++----- packaging/compose/agent/compose.yaml | 26 ++- packaging/compose/backend/compose.yaml | 15 ++ packaging/compose/probe/compose.yaml | 22 +-- .../systemd/monitor-agent-container.service | 2 +- .../systemd/monitor-agent-docker.service | 16 ++ .../systemd/monitor-agent-rootless.service | 16 ++ .../systemd/monitor-probe-container.service | 2 +- .../systemd/monitor-probe-docker.service | 16 ++ .../systemd/monitor-probe-rootless.service | 16 ++ packaging/update/install-updater.sh | 174 ++++++++++++------ packaging/update/monitor-update.sh | 114 +++++++++--- scripts/test-container-updater.py | 122 ++++++++++++ 16 files changed, 473 insertions(+), 156 deletions(-) create mode 100644 packaging/compose/backend/compose.yaml create mode 100644 packaging/systemd/monitor-agent-docker.service create mode 100644 packaging/systemd/monitor-agent-rootless.service create mode 100644 packaging/systemd/monitor-probe-docker.service create mode 100644 packaging/systemd/monitor-probe-rootless.service create mode 100644 scripts/test-container-updater.py diff --git a/backend/agent/README.md b/backend/agent/README.md index 48fab1a..c52e71a 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -47,7 +47,7 @@ fleet-enroll --server https:// --join --name --ide monitor-agent --config /etc/monitor/agent.json ``` -容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取 `/etc/monitor/agent.json`,需要挂载配置与身份目录;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,挂载目录必须允许该用户读取配置和身份文件。 +容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/monitor/monitor-agent:`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/monitor/agent.json` 与身份文件;建议使用宿主网络,并按 ICMP 测量需求添加 `NET_RAW` 能力。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。 不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 @@ -57,3 +57,5 @@ monitor-agent --config /etc/monitor/agent.json - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。 + +Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 diff --git a/backend/probe/README.md b/backend/probe/README.md index 4d3066c..6e764cc 100644 --- a/backend/probe/README.md +++ b/backend/probe/README.md @@ -13,7 +13,7 @@ Backend ──任务──> Agent <──测量会话──> Probe Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。 -容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/monitor/monitor-probe:`,容器默认读取 `/etc/monitor/probe.json`,数据目录要允许 UID/GID 1654 读写。 +容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/monitor/monitor-probe:`,容器默认读取 `/etc/monitor/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。 ## 构建 @@ -49,3 +49,5 @@ monitor-probe --config /etc/monitor/probe.json - Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。 - 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 - 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。 + +Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 diff --git a/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs b/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs index 4f21963..255268a 100644 --- a/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs +++ b/backend/shared/Monitor.Measurement/Update/UpdateBridge.cs @@ -7,7 +7,7 @@ namespace Monitor.Measurement; /// /// 节点进程和宿主机更新器之间的文件交换(容器里 /etc/monitor,也就是数据目录): -/// 节点把面板发来的清单和签名写进 update/,把「更新」请求写成 update/apply.request;更新器验签并更新,状态写在 root 拥有的 update-status/status.json。 +/// 节点把面板发来的清单和签名写进 update/,把「更新」请求写成 update/apply.request;更新器验签并更新,状态由宿主更新器写在只读挂载的 update-status/status.json。 /// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。 /// public sealed class UpdateBridge diff --git a/docs/deployment.md b/docs/deployment.md index 2c2b4d9..c66a03d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -201,41 +201,28 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2 这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 -## 容器节点的运行(podman + systemd) +## 容器部署(Docker / Podman 共用命名 volume) -Agent 和 Probe 推荐在宿主机上用 root 的 podman 加 systemd 运行,这样宿主机更新器(下一节)才能重启它们。以 Agent 为例(Probe 把名字和目录换成 `monitor-probe`,配置文件换成 `probe.json`): +镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `monitor-agent-config`,Probe 保存在 `monitor-probe-config`,更新状态使用独立的 `monitor--status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。 -```ini -# /etc/systemd/system/monitor-agent-container.service -[Unit] -Description=Monitor Agent (podman) -After=network-online.target -Wants=network-online.target +在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`): -[Service] -EnvironmentFile=/etc/monitor-update/agent.image # IMAGE=<仓库>@sha256:,更新器更新时改写它 -Restart=always -RestartSec=5 -TimeoutStopSec=30 -ExecStartPre=-/usr/bin/podman rm -f monitor-agent -ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v /opt/monitor/etc:/etc/monitor ${IMAGE} -ExecStop=/usr/bin/podman stop -t 20 monitor-agent - -[Install] -WantedBy=multi-user.target +```sh +podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll agent \ + --server https:// --join --name --identity-dir /etc/monitor/agent-id +podman compose run --rm -T --entrypoint /bin/sh agent -c 'cat > /etc/monitor/agent.json' <<'JSON' +{"fleet":{"server":"https://","identity_dir":"/etc/monitor/agent-id"},"listen":{"bind":"0.0.0.0"}} +JSON +podman compose up -d ``` -- **数据目录**(上面挂到容器 `/etc/monitor` 的 `/opt/monitor/etc`)归容器用户(UID/GID 1654),里面放 `agent.json`、身份目录(`fleet-enroll` 注册之后的 `agent-id/`),Probe 放 `probe.json` 和 `agents.json`。`agent.json` 里的 `fleet.identity_dir` 写容器里的路径(例如 `/etc/monitor/agent-id`)。 -- **首次注册 Agent**:`sudo podman run --rm --network host --security-opt label=disable -v <数据目录>:/etc/monitor --entrypoint /usr/local/bin/fleet-enroll <镜像> --server https://<面板> --join <加入令牌> --name <名字> --identity-dir /etc/monitor/agent-id`,再写 `agent.json`。Probe:在管理端创建(或轮换密钥)之后下载 `probe.json`,放进数据目录。 -- **拉取私有镜像**需要 Registry 的登录信息:用 `podman login --authfile /etc/monitor-update/registry-auth.json …` 生成一份只有 root 能读(600)的认证文件,更新器用它拉取,容器本身用不到。 -- **挂载宿主机的 `/etc/os-release`(只读)**:节点读系统名展示在面板上,不挂的话读到的是镜像里的 Alpine。 -- **ICMP 目标任务**要在 `podman run` 里加 `--cap-add NET_RAW`。 -- **经隧道注册之后检查 Agent 的 `public_host`**:面板看到的来源地址是隧道的出口(通常是内网地址),要在管理端改成这台机器自己的公网地址(`PATCH /api/admin/agents/{id}`),Probe 才连得上。 -- `packaging/compose/` 里的 compose 文件可用 `sudo podman compose up -d` 或 `docker compose up -d`;Podman Compose 需要安装 Compose provider。更新靠手动改版本再 `sudo podman compose pull && sudo podman compose up -d`,面板的更新按钮对它不起作用。 +Probe 在 `packaging/compose/probe` 下,将面板下载的身份文件通过标准输入导入 volume:`podman compose run --rm -T --entrypoint /bin/sh probe -c 'umask 077; cat > /etc/monitor/probe.json' < probe.json`,再 `podman compose up -d`。私钥权限为 600。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册或轮换密钥。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。 -### Podman 的宿主指标与权限 +纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `monitor--rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/monitor/.image`;rootful Podman 使用 `monitor--container.service`,Docker 使用 `monitor--docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/monitor-update/.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now ` 或 `sudo systemctl enable --now `。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。 -这里使用 Linux 上的 **rootful Podman**(`sudo podman`),容器内仍以 UID/GID 1654 运行。配置目录需由 1654 用户读写;`label=disable` 不会改变 Unix 文件权限。Rootless Podman 的 UID 映射不同,不能直接沿用 `chown 1654:1654` 的准备步骤。 +Backend 的 `packaging/compose/backend/compose.yaml` 使用 `monitor-backend-data` 保存数据库和原始数据,设置 `MONITOR_INITIAL_ADMIN_PASSWORD` 后即可启动。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。 + +### Agent 的系统指标挂载 Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 示例也采用相同配置: @@ -252,29 +239,24 @@ Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd Probe 保持现有功能,只上报系统、架构与节点状态,不上报 CPU、内存、磁盘或网卡指标。因此 Probe 的 Compose 模板只需要配置目录和 `/etc/os-release` 挂载;从 Agent systemd 示例改为 Probe 时,去掉 `/proc`、`/sys`、`/` 挂载及 `HOST_*` 环境变量。 -可直接安装的 unit 模板见 `packaging/systemd/monitor-agent-container.service` 和 `packaging/systemd/monitor-probe-container.service`;它们与上述示例使用相同策略,分别读取 `/etc/monitor-update/agent.image` 与 `probe.image`。在 Linux 虚拟机上运行容器时,采集对象是该 Linux 虚拟机。 - ## 容器节点的更新(Agent、Probe) -面板通知、节点验签、宿主机更新,**面板即使被攻破也装不上假镜像**: +更新器支持 rootless Podman 用户服务、rootful Podman 和 Docker 系统服务。配置、身份和更新请求通过命名 volume 传递,不需要指定宿主数据目录。短时辅助容器读取请求、写入独立状态 volume;辅助镜像必须已在本机拉取,更新器不会自动拉取辅助镜像。配置、公钥和已安装版本记录留在容器外。 -```text -CI 构建并推镜像(Zot)──► ci/release-manifest.sh:对镜像 digest 的清单签名(ECDSA P-256,私钥只在 Woodpecker 密钥 release_signing_key 里) - └─ POST /api/admin/releases ──► 面板:只存下并原样转发(不验签)──► 在线的 Agent、Probe(连上时补发) - 节点进程:只把清单和签名写进数据目录的 update/,不执行任何命令 - │ systemd.path 监视到 offer.sig - ▼ - 宿主机 monitor-update(root):复制到临时目录 → 用本机的发布公钥验签 → 核对组件和镜像仓库 → seq 必须比已安装的大 - → status:available / invalid(写进 update-status/,节点随心跳报给面板,管理端「版本」页显示) - → 管理员点「更新」(或本机配置 POLICY=auto)→ podman pull <仓库>@sha256:(内容寻址,Registry 或链路上换不了) - → 写 IMAGE=… 到 /etc/monitor-update/<组件>.image,重启容器单元;20 秒内没有健康就换回旧镜像(rolled_back) +Rootless Podman 普通用户安装示例(先将对应 rootless 服务单元复制到用户 systemd 目录): + +```sh +sh packaging/update/install-updater.sh agent --user \ + --volume monitor-agent-config --status-volume monitor-agent-status \ + --helper-image registry.oeo.one/monitor/monitor-agent: \ + --image-repo registry.oeo.one/monitor/monitor-agent \ + --unit monitor-agent-rootless.service --policy auto ``` -- **安装更新器**(每个容器节点一次,root):`packaging/update/install-updater.sh agent --data-dir /opt/monitor/etc --image-repo registry.oeo.one/monitor/monitor-agent --unit monitor-agent-container.service [--policy auto] [--authfile ]`。它装好 `monitor-update` 脚本和发布公钥,写 `/etc/monitor-update/<组件>.conf`,启用两个 systemd 路径单元。容器单元要用 `EnvironmentFile=/etc/monitor-update/<组件>.image` 和 `${IMAGE}` 启动容器。 -- **镜像仓库、服务名、公钥都在本机配置里**,清单里写什么都不会改变拉取哪个仓库;清单被篡改、签名对不上、组件或仓库不符、`seq` 不比已安装的大(重放旧清单),都会被拒绝,状态是 `invalid`。 -- **业务进程不能改二进制,也不执行命令**:它只能往 `update/` 里放清单和请求;状态在 root 拥有的 `update-status/` 里(只读)。 -- **CI 的配置**:Woodpecker 密钥 `release_signing_key`(发布私钥,PEM)、`release_api_token`(面板的 API 令牌,作用域 `release`,只能登记清单);环境变量 `RELEASE_API_URL`(面板地址)。没有 `release_signing_key` 就只构建镜像、不发清单。 -- **管理端**:「系统 → 版本」页显示最新发布的 tag、每个节点的更新状态(已是最新、有新版本、更新中、已回滚、清单无效),有新版本的节点有「更新」按钮,也可以「全部更新」。更新策略在节点本机配置:`/etc/monitor-update/<组件>.conf` 里 `POLICY=manual`(默认,等管理员请求)或 `auto`(验签通过就更新)。 -- **发布公钥**在 `packaging/update/release-key.pub.pem`;私钥不入库,请离线备份。更换密钥:节点上换新公钥之后再用新私钥发布。 -- 纯二进制的安装方式(`fleet-updater`、`fleet-release`,见 [fleet/update.md](fleet/update.md))还没有实现;容器方式优先。 +使用 Docker 时以管理员运行相同安装命令,去掉 `--user`,加 `--engine docker`,单元选择 `monitor-agent-docker.service`。rootful Podman 去掉 `--user`,单元选择 `monitor-agent-container.service`。Probe 替换角色名。初始化对应 `.image` 为 `IMAGE=<当前镜像引用>` 后启动容器单元。辅助镜像可用本机已有的 digest 引用,避免标签变化。Docker rootless 更新尚未适配。 +命名 volume 的更新服务由 systemd timer 每 30 秒检查一次。`--policy auto` 验签后自动应用,默认 `manual` 等待面板写入应用请求。清单必须通过 ECDSA P-256 签名校验,组件、镜像仓库和递增 seq 都必须匹配本机配置;镜像按签名清单中的 digest 拉取。更新后观察服务 20 秒,启动失败或崩溃循环会恢复旧镜像引用并重启。无效签名、重放清单、拉取失败不会切换当前镜像。同一组件的更新通过 flock 串行处理。 + +更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/monitor/`、版本记录放在 `~/.local/state/monitor-update/`;系统部署使用 `/etc/monitor-update/` 和 `/var/lib/monitor-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。 + +发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 diff --git a/packaging/compose/agent/compose.yaml b/packaging/compose/agent/compose.yaml index 488e45a..567d78b 100644 --- a/packaging/compose/agent/compose.yaml +++ b/packaging/compose/agent/compose.yaml @@ -1,17 +1,6 @@ -# Agent:配置和身份都放在同目录的 .data/(挂载到容器的 /etc/monitor)。 -# -# 第一次使用: -# mkdir -p .data && sudo chown 1654:1654 .data # 容器以 UID/GID 1654 运行 -# MONITOR_JOIN_TOKEN=<加入令牌> MONITOR_SERVER=https:// MONITOR_AGENT_NAME=<名字> \ -# docker compose run --rm --entrypoint /usr/local/bin/fleet-enroll agent \ -# --server "$MONITOR_SERVER" --join "$MONITOR_JOIN_TOKEN" --name "$MONITOR_AGENT_NAME" --identity-dir /etc/monitor/agent-id -# 再在 .data/agent.json 里写(fleet.identity_dir 要和上面一致): -# {"fleet":{"server":"https://","identity_dir":"/etc/monitor/agent-id"},"listen":{"bind":"0.0.0.0"},"limits":{"max_total_mbps":100,"max_total_pps":10000}} -# sudo chown -R 1654:1654 .data -# sudo podman compose up -d # Linux rootful Podman;也可用 docker compose -# -# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 -# 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动,身份和历史都保留。 +# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。 +# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。 +# 首次注册、配置导入和自动更新见 docs/deployment.md。 services: agent: image: ${MONITOR_IMAGE:-registry.oeo.one/monitor/monitor-agent}:${MONITOR_VERSION:-0.1.1} @@ -21,7 +10,8 @@ services: # Probe 要直连 Agent 的 7443(TCP+UDP)和 7444(TCP),用宿主网络,不做端口映射 network_mode: host volumes: - - ./.data:/etc/monitor + - agent-config:/etc/monitor + - agent-status:/etc/monitor/update-status:ro - /etc/os-release:/etc/os-release:ro # 系统目录只读,不加 :z/:Z;SELinux 隔离在容器级别禁用。 - /proc:/host/proc:ro @@ -43,3 +33,9 @@ services: # cap_add: # - NET_RAW stop_grace_period: 20s + +volumes: + agent-config: + name: monitor-agent-config + agent-status: + name: monitor-agent-status diff --git a/packaging/compose/backend/compose.yaml b/packaging/compose/backend/compose.yaml new file mode 100644 index 0000000..0e1be0c --- /dev/null +++ b/packaging/compose/backend/compose.yaml @@ -0,0 +1,15 @@ +# Docker / Podman 共用,数据库和原始数据保存在命名 volume 中。 +services: + backend: + image: ${MONITOR_IMAGE:-registry.oeo.one/monitor/monitor-backend}:${MONITOR_VERSION:-0.1.1} + container_name: monitor-backend + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + Monitor__InitialAdminPassword: ${MONITOR_INITIAL_ADMIN_PASSWORD:?Set MONITOR_INITIAL_ADMIN_PASSWORD} + volumes: + - backend-data:/data +volumes: + backend-data: + name: monitor-backend-data diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index 09d7658..98aea62 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -1,13 +1,6 @@ -# Probe:身份配置 probe.json 和 Agent 列表缓存 agents.json 都放在同目录的 .data/(挂载到容器的 /etc/monitor)。 -# -# 第一次使用: -# mkdir -p .data && sudo chown 1654:1654 .data # 容器以 UID/GID 1654 运行,需要读写这个目录 -# 在管理端创建 Probe,下载一次性身份配置,保存为 .data/probe.json(含私钥,权限 600,不要提交到版本控制) -# sudo chown -R 1654:1654 .data # probe.json 的 600 权限要求文件也归容器用户所有 -# sudo podman compose up -d # Linux rootful Podman;也可用 docker compose -# -# 更新:改 MONITOR_VERSION(或 MONITOR_IMAGE),再 docker compose pull && docker compose up -d。 -# 这种方式没有宿主机更新器:管理端的「更新」按钮对它不起作用;要自动更新请用 podman + systemd(docs/deployment.md「容器节点的运行」)。.data 不动。 +# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。 +# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。 +# 首次注册、配置导入和自动更新见 docs/deployment.md。 services: probe: image: ${MONITOR_IMAGE:-registry.oeo.one/monitor/monitor-probe}:${MONITOR_VERSION:-0.1.1} @@ -16,7 +9,8 @@ services: init: true network_mode: host volumes: - - ./.data:/etc/monitor + - probe-config:/etc/monitor + - probe-status:/etc/monitor/update-status:ro - /etc/os-release:/etc/os-release:ro read_only: true tmpfs: @@ -30,3 +24,9 @@ services: # cap_add: # - NET_RAW stop_grace_period: 20s + +volumes: + probe-config: + name: monitor-probe-config + probe-status: + name: monitor-probe-status diff --git a/packaging/systemd/monitor-agent-container.service b/packaging/systemd/monitor-agent-container.service index dafa366..5f285e1 100644 --- a/packaging/systemd/monitor-agent-container.service +++ b/packaging/systemd/monitor-agent-container.service @@ -9,7 +9,7 @@ Restart=always RestartSec=5 TimeoutStopSec=30 ExecStartPre=-/usr/bin/podman rm -f monitor-agent -ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v /opt/monitor/etc:/etc/monitor ${IMAGE} +ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v monitor-agent-config:/etc/monitor -v monitor-agent-status:/etc/monitor/update-status:ro ${IMAGE} ExecStop=/usr/bin/podman stop -t 20 monitor-agent [Install] diff --git a/packaging/systemd/monitor-agent-docker.service b/packaging/systemd/monitor-agent-docker.service new file mode 100644 index 0000000..c24724b --- /dev/null +++ b/packaging/systemd/monitor-agent-docker.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Agent (Docker container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/agent.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/docker rm -f monitor-agent +ExecStart=/usr/bin/docker run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v monitor-agent-config:/etc/monitor -v monitor-agent-status:/etc/monitor/update-status:ro ${IMAGE} +ExecStop=/usr/bin/docker stop -t 20 monitor-agent + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/monitor-agent-rootless.service b/packaging/systemd/monitor-agent-rootless.service new file mode 100644 index 0000000..5278a8d --- /dev/null +++ b/packaging/systemd/monitor-agent-rootless.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Agent (rootless Podman container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=%h/.config/monitor/agent.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-agent +ExecStart=/usr/bin/podman run --rm --name monitor-agent --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /proc:/host/proc:ro -v /sys:/host/sys:ro -v /:/host/root:ro -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys -e HOST_ROOT=/host/root -v monitor-agent-config:/etc/monitor -v monitor-agent-status:/etc/monitor/update-status:ro ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-agent + +[Install] +WantedBy=default.target diff --git a/packaging/systemd/monitor-probe-container.service b/packaging/systemd/monitor-probe-container.service index 28f8630..015f69b 100644 --- a/packaging/systemd/monitor-probe-container.service +++ b/packaging/systemd/monitor-probe-container.service @@ -9,7 +9,7 @@ Restart=always RestartSec=5 TimeoutStopSec=30 ExecStartPre=-/usr/bin/podman rm -f monitor-probe -ExecStart=/usr/bin/podman run --rm --name monitor-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v /opt/monitor-probe/etc:/etc/monitor ${IMAGE} +ExecStart=/usr/bin/podman run --rm --name monitor-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v monitor-probe-config:/etc/monitor -v monitor-probe-status:/etc/monitor/update-status:ro ${IMAGE} ExecStop=/usr/bin/podman stop -t 20 monitor-probe [Install] diff --git a/packaging/systemd/monitor-probe-docker.service b/packaging/systemd/monitor-probe-docker.service new file mode 100644 index 0000000..9d7b06a --- /dev/null +++ b/packaging/systemd/monitor-probe-docker.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Probe (Docker container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/monitor-update/probe.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/docker rm -f monitor-probe +ExecStart=/usr/bin/docker run --rm --name monitor-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v monitor-probe-config:/etc/monitor -v monitor-probe-status:/etc/monitor/update-status:ro ${IMAGE} +ExecStop=/usr/bin/docker stop -t 20 monitor-probe + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/monitor-probe-rootless.service b/packaging/systemd/monitor-probe-rootless.service new file mode 100644 index 0000000..723fce4 --- /dev/null +++ b/packaging/systemd/monitor-probe-rootless.service @@ -0,0 +1,16 @@ +[Unit] +Description=Monitor Probe (rootless Podman container) +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=%h/.config/monitor/probe.image +Restart=always +RestartSec=5 +TimeoutStopSec=30 +ExecStartPre=-/usr/bin/podman rm -f monitor-probe +ExecStart=/usr/bin/podman run --rm --name monitor-probe --network host --read-only --security-opt no-new-privileges --security-opt label=disable --cap-drop all --tmpfs /tmp:rw,noexec,nosuid,size=64m,mode=1777 -v /etc/os-release:/etc/os-release:ro -v monitor-probe-config:/etc/monitor -v monitor-probe-status:/etc/monitor/update-status:ro ${IMAGE} +ExecStop=/usr/bin/podman stop -t 20 monitor-probe + +[Install] +WantedBy=default.target diff --git a/packaging/update/install-updater.sh b/packaging/update/install-updater.sh index 6733662..0752b7b 100644 --- a/packaging/update/install-updater.sh +++ b/packaging/update/install-updater.sh @@ -1,89 +1,155 @@ #!/bin/sh -# 在容器节点的宿主机上安装更新器: -# install-updater.sh --data-dir <挂载到容器 /etc/monitor 的宿主机目录> --image-repo <镜像仓库,不带 tag> --unit <容器的 systemd 单元名> -# [--policy auto|manual] [--authfile <拉镜像用的 Registry 认证文件>] -# 需要 root、systemd、podman、openssl。做的事:装脚本和发布公钥,写本机配置,装两个 systemd 单元(有新清单就检查,有「更新」请求就更新)。 -# 容器的单元要从 EnvironmentFile=/etc/monitor-update/<组件>.image 读 IMAGE=…,并用 ${IMAGE} 启动容器(没有这个文件时先写一份当前镜像)。 +# Install signed OCI updates for system services (Podman/Docker) or rootless Podman user services. +# Rootless: install-updater.sh agent --user --volume V --helper-image I --image-repo R --unit U +# System: sudo install-updater.sh agent --engine podman|docker --volume V --helper-image I --image-repo R --unit U set -eu COMPONENT="${1:-}"; shift || true -case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh --data-dir D --image-repo R --unit U [--policy auto|manual] [--authfile F]" >&2; exit 2 ;; esac -DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY="manual"; AUTHFILE="" +case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac +DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY=manual; AUTHFILE=""; ENGINE=podman; SERVICE_SCOPE=system; ENABLE=true; DATA_VOLUME=""; STATUS_VOLUME=""; HELPER_IMAGE="" while [ $# -gt 0 ]; do case "$1" in + --user) SERVICE_SCOPE=user; shift ;; + --engine) ENGINE="$2"; shift 2 ;; + --volume) DATA_VOLUME="$2"; shift 2 ;; + --status-volume) STATUS_VOLUME="$2"; shift 2 ;; + --helper-image) HELPER_IMAGE="$2"; shift 2 ;; --data-dir) DATA_DIR="$2"; shift 2 ;; --image-repo) IMAGE_REPO="$2"; shift 2 ;; --unit) UNIT="$2"; shift 2 ;; --policy) POLICY="$2"; shift 2 ;; --authfile) AUTHFILE="$2"; shift 2 ;; + --no-enable) ENABLE=false; shift ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done -[ -n "$DATA_DIR" ] && [ -n "$IMAGE_REPO" ] && [ -n "$UNIT" ] || { echo "--data-dir, --image-repo and --unit are required" >&2; exit 2; } +if [ -z "$IMAGE_REPO" ] || [ -z "$UNIT" ] || { [ -z "$DATA_DIR" ] && [ -z "$DATA_VOLUME" ]; }; then + echo "--image-repo, --unit and --volume (or legacy --data-dir) are required" >&2; exit 2 +fi case "$POLICY" in auto|manual) ;; *) echo "--policy must be auto or manual" >&2; exit 2 ;; esac -[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } +case "$ENGINE" in podman|docker) ;; *) echo "--engine must be podman or docker" >&2; exit 2 ;; esac +if [ -n "$DATA_VOLUME" ]; then + [ -z "$DATA_DIR" ] || { echo "use --volume or --data-dir, not both" >&2; exit 2; } + [ -n "$HELPER_IMAGE" ] || { echo "--volume needs --helper-image (an already pulled Monitor image)" >&2; exit 2; } + STATUS_VOLUME="${STATUS_VOLUME:-monitor-$COMPONENT-status}" + "$ENGINE" image inspect "$HELPER_IMAGE" >/dev/null + "$ENGINE" volume inspect "$DATA_VOLUME" >/dev/null 2>&1 || "$ENGINE" volume create "$DATA_VOLUME" >/dev/null + "$ENGINE" volume inspect "$STATUS_VOLUME" >/dev/null 2>&1 || "$ENGINE" volume create "$STATUS_VOLUME" >/dev/null +else + case "$DATA_DIR" in /*) ;; *) echo "--data-dir must be absolute" >&2; exit 2 ;; esac +fi + +if [ "$SERVICE_SCOPE" = user ]; then + [ "$(id -u)" -ne 0 ] || { echo "--user must run as the rootless Podman user, without sudo" >&2; exit 1; } + [ "$ENGINE" = podman ] || { echo "--user currently supports rootless Podman; Docker uses system services" >&2; exit 2; } + CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/monitor" + STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/monitor-update" + UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + BIN_DIR="$HOME/.local/bin" + TARGET=default.target +else + [ "$(id -u)" -eq 0 ] || { echo "system installation needs root; use --user for rootless Podman" >&2; exit 1; } + CONF_DIR=/etc/monitor-update + STATE_DIR=/var/lib/monitor-update + UNIT_DIR=/etc/systemd/system + BIN_DIR=/usr/local/sbin + TARGET=multi-user.target +fi +service_ctl() { + if [ "$SERVICE_SCOPE" = user ]; then systemctl --user "$@"; else systemctl "$@"; fi +} +# Config is sourced by the updater; quote arguments as shell data. +shell_quote() { printf "'"; printf '%s' "$1" | sed "s/'/'\"'\"'/g"; printf "'"; } +unit_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } HERE="$(cd "$(dirname "$0")" && pwd)" -install -d -m 755 /etc/monitor-update /var/lib/monitor-update -install -m 755 "$HERE/monitor-update.sh" /usr/local/sbin/monitor-update -install -m 644 "$HERE/release-key.pub.pem" /etc/monitor-update/release-key.pub.pem +install -d -m 755 "$CONF_DIR" "$STATE_DIR" "$UNIT_DIR" "$BIN_DIR" +BIN="$BIN_DIR/monitor-update" +install -m 755 "$HERE/monitor-update.sh" "$BIN" +install -m 644 "$HERE/release-key.pub.pem" "$CONF_DIR/release-key.pub.pem" +if [ -n "$DATA_VOLUME" ]; then + : # Named volumes are initialized by the image; no host directory or chown needed. +elif [ "$SERVICE_SCOPE" = user ]; then + install -d -m 700 "$DATA_DIR/update" +else + install -d -m 755 -o 1654 -g 1654 "$DATA_DIR/update" +fi +if [ -z "$DATA_VOLUME" ]; then install -d -m 755 "$DATA_DIR/update-status"; fi +# Rootless service templates mount update-status separately read-only. +ENV_FILE="$CONF_DIR/$COMPONENT.image" +PUBKEY="$CONF_DIR/release-key.pub.pem" +{ + for name in DATA_DIR IMAGE_REPO UNIT PUBKEY ENV_FILE STATE_DIR POLICY AUTHFILE ENGINE SERVICE_SCOPE DATA_VOLUME STATUS_VOLUME HELPER_IMAGE; do + case "$name" in + DATA_DIR) value="$DATA_DIR" ;; IMAGE_REPO) value="$IMAGE_REPO" ;; UNIT) value="$UNIT" ;; + PUBKEY) value="$PUBKEY" ;; ENV_FILE) value="$ENV_FILE" ;; STATE_DIR) value="$STATE_DIR" ;; + DATA_VOLUME) value="$DATA_VOLUME" ;; STATUS_VOLUME) value="$STATUS_VOLUME" ;; HELPER_IMAGE) value="$HELPER_IMAGE" ;; + POLICY) value="$POLICY" ;; AUTHFILE) value="$AUTHFILE" ;; ENGINE) value="$ENGINE" ;; SERVICE_SCOPE) value="$SERVICE_SCOPE" ;; + esac + printf '%s=' "$name"; shell_quote "$value"; printf '\n' + done +} > "$CONF_DIR/$COMPONENT.conf" +chmod 600 "$CONF_DIR/$COMPONENT.conf" -# 容器用户(UID 1654)能写 update/(放清单和请求);update-status/ 归 root,业务进程只读 -install -d -m 755 -o 1654 -g 1654 "$DATA_DIR/update" -install -d -m 755 "$DATA_DIR/update-status" - -cat > "/etc/monitor-update/$COMPONENT.conf" < "/etc/systemd/system/monitor-update-check-$COMPONENT.service" < "$UNIT_DIR/monitor-update-$COMPONENT.service" < "/etc/systemd/system/monitor-update-apply-$COMPONENT.service" < "/etc/systemd/system/monitor-update-check-$COMPONENT.path" < "$UNIT_DIR/monitor-update-$COMPONENT.timer" < "/etc/systemd/system/monitor-update-apply-$COMPONENT.path" < "$UNIT_DIR/monitor-update-$action-$COMPONENT.service" < "$UNIT_DIR/monitor-update-$action-$COMPONENT.path" </dev/null 2>&1 || true + service_ctl enable --now "monitor-update-$COMPONENT.timer" + else + service_ctl disable --now "monitor-update-$COMPONENT.timer" >/dev/null 2>&1 || true + service_ctl enable --now "monitor-update-check-$COMPONENT.path" "monitor-update-apply-$COMPONENT.path" + fi +fi +echo "updater installed for $COMPONENT (engine: $ENGINE, scope: $SERVICE_SCOPE, policy: $POLICY)" +echo "container service must read IMAGE from $ENV_FILE" diff --git a/packaging/update/monitor-update.sh b/packaging/update/monitor-update.sh index 987af08..309038c 100644 --- a/packaging/update/monitor-update.sh +++ b/packaging/update/monitor-update.sh @@ -7,6 +7,8 @@ # - 镜像仓库、组件、服务名都来自本机的配置(/etc/monitor-update/<组件>.conf),清单里写什么都不会改变拉哪个仓库。 # - 清单的 seq 必须比已安装的大(防止旧清单被重放回滚)。 # - 业务进程(Agent、Probe)只能往数据目录里写「清单」和「请求」文件,更新由这个宿主机脚本完成,业务进程不执行任何命令。 +# Shell snippets below execute inside helper containers. +# shellcheck disable=SC2016 set -eu CMD="${1:-}" @@ -14,24 +16,63 @@ COMPONENT="${2:-}" case "$CMD" in check|apply) ;; *) echo "usage: monitor-update " >&2; exit 2 ;; esac case "$COMPONENT" in agent|probe) ;; *) echo "component must be agent or probe" >&2; exit 2 ;; esac -CONF="${MONITOR_UPDATE_CONF_DIR:-/etc/monitor-update}/$COMPONENT.conf" +if [ "$(id -u)" -eq 0 ]; then + DEFAULT_CONF_DIR=/etc/monitor-update +else + DEFAULT_CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/monitor" +fi +CONF="${MONITOR_UPDATE_CONF_DIR:-$DEFAULT_CONF_DIR}/$COMPONENT.conf" [ -r "$CONF" ] || { echo "missing $CONF" >&2; exit 2; } # 配置:DATA_DIR(容器里 /etc/monitor 对应的宿主机目录)、IMAGE_REPO、UNIT、PUBKEY、POLICY(auto|manual)、AUTHFILE(可选)、 # STATE_DIR(已安装版本的记录)、ENV_FILE(容器单元读取的 IMAGE=… 文件) # shellcheck disable=SC1090 . "$CONF" -: "${DATA_DIR:?}" "${IMAGE_REPO:?}" "${UNIT:?}" "${PUBKEY:?}" "${ENV_FILE:?}" +: "${IMAGE_REPO:?}" "${UNIT:?}" "${PUBKEY:?}" "${ENV_FILE:?}" POLICY="${POLICY:-manual}" STATE_DIR="${STATE_DIR:-/var/lib/monitor-update}" +ENGINE="${ENGINE:-podman}" +SERVICE_SCOPE="${SERVICE_SCOPE:-system}" +DATA_VOLUME="${DATA_VOLUME:-}" +STATUS_VOLUME="${STATUS_VOLUME:-monitor-$COMPONENT-status}" +case "$ENGINE" in podman|docker) ;; *) echo "unsupported engine: $ENGINE" >&2; exit 2 ;; esac +case "$SERVICE_SCOPE" in system|user) ;; *) echo "unsupported service scope: $SERVICE_SCOPE" >&2; exit 2 ;; esac +service_ctl() { + if [ "$SERVICE_SCOPE" = user ]; then systemctl --user "$@"; else systemctl "$@"; fi +} +pull_image() { + if [ -n "${AUTHFILE:-}" ]; then + if [ "$ENGINE" = podman ]; then + "$ENGINE" pull --quiet --authfile "$AUTHFILE" "$1" + else + mkdir -m 700 "$WORK/docker-auth" + cp "$AUTHFILE" "$WORK/docker-auth/config.json" + DOCKER_CONFIG="$WORK/docker-auth" "$ENGINE" pull --quiet "$1" + fi + else + "$ENGINE" pull --quiet "$1" + fi +} # 业务进程(容器里的 Agent、Probe)可写的目录只有 update/:它在里面放 offer.manifest、offer.sig、apply.request。 -# 这个脚本以 root 运行,所以:只读取普通文件(不跟符号链接)、读取之后先复制到自己的临时目录再验证和使用(防止验证之后被换掉), -# 并且绝不往 update/ 里写任何东西;状态写在 root 拥有的 update-status/ 里(业务进程只读)。 -UPDATE_DIR="$DATA_DIR/update" -STATUS_DIR="$DATA_DIR/update-status" -STATUS="$STATUS_DIR/status.json" +# 更新器在容器外运行:只读取普通文件(不跟符号链接)、读取之后先复制到自己的临时目录再验证和使用(防止验证之后被换掉), +# 状态目录在 rootless 容器里另行只读挂载;更新器配置和已安装版本状态不挂入容器。 +if [ -n "$DATA_VOLUME" ]; then + # Read/write volume contents via short-lived engine-managed helpers, without host mountpoint access. + : "${HELPER_IMAGE:?}" + "$ENGINE" volume inspect "$DATA_VOLUME" >/dev/null + "$ENGINE" volume inspect "$STATUS_VOLUME" >/dev/null +else + : "${DATA_DIR:?}" + UPDATE_DIR="$DATA_DIR/update" + STATUS_DIR="$DATA_DIR/update-status" + STATUS="$STATUS_DIR/status.json" + mkdir -p "$STATUS_DIR" + chmod 755 "$STATUS_DIR" +fi INSTALLED="$STATE_DIR/$COMPONENT.installed" # 两行:seq、digest -mkdir -p "$STATE_DIR" "$STATUS_DIR" -chmod 755 "$STATUS_DIR" +mkdir -p "$STATE_DIR" +# Serialize check/apply path events for this component. +exec 9>"$STATE_DIR/$COMPONENT.lock" +flock -w 290 9 || { echo "another update is still running" >&2; exit 1; } WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT OFFER_MANIFEST="$WORK/offer.manifest" @@ -41,6 +82,12 @@ OFFER_SIG="$WORK/offer.sig" fetch_offer() { rm -f "$OFFER_MANIFEST" "$OFFER_SIG" for name in offer.manifest offer.sig; do + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/etc/monitor:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c 'f="/etc/monitor/update/$1"; if [ -f "$f" ] && [ ! -L "$f" ]; then head -c 8192 "$f"; fi' sh "$name" > "$WORK/$name" + continue + fi f="$UPDATE_DIR/$name" if [ -f "$f" ] && [ ! -L "$f" ]; then head -c 8192 "$f" > "$WORK/$name"; fi done @@ -48,13 +95,22 @@ fetch_offer() { now() { date +%s; } -# 状态文件:Agent / Probe 读它并随心跳报给面板。root 写,业务进程只读。 +# 状态文件:更新器写,Agent / Probe 从只读挂载中读取并随心跳上报。 write_status() { # state available_json error - tmp="$(mktemp "$STATUS_DIR/.status.XXXXXX")" + tmp="$WORK/status.json" printf '{"state":"%s","current":"%s","available":%s,"error":"%s","at":%s}\n' \ "$1" "$(current_digest)" "${2:-null}" "$(printf '%s' "${3:-}" | tr -d '"\\\n' | cut -c1-200)" "$(now)" > "$tmp" - chmod 644 "$tmp" - mv -f "$tmp" "$STATUS" + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm -i --user 0 --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$STATUS_VOLUME:/status" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c 'tmp=$(mktemp /status/.status.XXXXXX); cat > "$tmp"; chmod 644 "$tmp"; mv -f "$tmp" /status/status.json' < "$tmp" + else + chmod 644 "$tmp" + destination="$(mktemp "$STATUS_DIR/.status.XXXXXX")" + cp "$tmp" "$destination" + chmod 644 "$destination" + mv -f "$destination" "$STATUS" + fi } current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; } @@ -82,7 +138,7 @@ verify_offer() { [ "$M_SEQ" -gt "$(installed_seq)" ] || { ERR="stale manifest (seq $M_SEQ <= $(installed_seq))"; return 1; } } -available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '"\\')" "$(printf '%s' "$M_TAG" | tr -d '"\\')" "$M_DIGEST" "$M_SEQ"; } +available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '\042\134')" "$(printf '%s' "$M_TAG" | tr -d '\042\134')" "$M_DIGEST" "$M_SEQ"; } do_check() { fetch_offer @@ -105,25 +161,29 @@ do_check() { } do_apply() { - rm -f "$UPDATE_DIR/apply.request" + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/etc/monitor" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'rm -f /etc/monitor/update/apply.request' + else + rm -f "$UPDATE_DIR/apply.request" + fi fetch_offer verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; } avail="$(available_json)" write_status applying "$avail" "" ref="$IMAGE_REPO@$M_DIGEST" - if [ -n "${AUTHFILE:-}" ]; then export REGISTRY_AUTH_FILE="$AUTHFILE"; fi - if ! podman pull --quiet "$ref" >/dev/null 2>&1; then + if ! pull_image "$ref" >/dev/null 2>&1; then write_status failed "$avail" "pull failed"; echo "pull failed: $ref" >&2; return 1 fi prev="$(cat "$ENV_FILE" 2>/dev/null || true)" printf 'IMAGE=%s\n' "$ref" > "$ENV_FILE.new" && mv -f "$ENV_FILE.new" "$ENV_FILE" - systemctl restart "$UNIT" || true + service_ctl restart "$UNIT" || true # 健康确认:服务在跑,并且 20 秒内没有再被重启(崩溃循环算失败) sleep 5 - before="$(systemctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" + before="$(service_ctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" sleep 15 - after="$(systemctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" - if systemctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then + after="$(service_ctl show -p NRestarts --value "$UNIT" 2>/dev/null || echo 0)" + if service_ctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED" write_status up_to_date null "" echo "updated to $M_TAG" @@ -131,12 +191,20 @@ do_apply() { fi echo "new version did not become healthy, rolling back" >&2 if [ -n "$prev" ]; then printf '%s\n' "$prev" > "$ENV_FILE"; fi - systemctl restart "$UNIT" || true + service_ctl restart "$UNIT" || true write_status rolled_back "$avail" "new version did not become healthy" return 1 } case "$CMD" in - check) do_check ;; + check) + if [ -n "$DATA_VOLUME" ] && [ "$POLICY" != auto ]; then + requested="$($ENGINE run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ + -v "$DATA_VOLUME:/etc/monitor:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c '[ ! -f /etc/monitor/update/apply.request ] || printf yes')" + if [ "$requested" = yes ]; then do_apply; else do_check; fi + else + do_check + fi ;; apply) do_apply ;; esac diff --git a/scripts/test-container-updater.py b/scripts/test-container-updater.py new file mode 100644 index 0000000..1af2566 --- /dev/null +++ b/scripts/test-container-updater.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Signed updater regression checks using fake engine/service CLIs and real signatures.""" +import base64 +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile + +SCRIPT = Path(__file__).resolve().parents[1] / "packaging/update/monitor-update.sh" +with tempfile.TemporaryDirectory(prefix="monitor-updater-test-") as directory: + root = Path(directory) + (root / "bin").mkdir() + (root / "data/update").mkdir(parents=True) + (root / "conf").mkdir() + key = root / "key.pem" + pub = root / "key.pub" + subprocess.run(["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", str(key)], check=True, capture_output=True) + subprocess.run(["openssl", "pkey", "-in", str(key), "-pubout", "-out", str(pub)], check=True, capture_output=True) + log = root / "calls" + mode = root / "mode" + # Only external effects are replaced; verification and state handling run unchanged. + for name in ("podman", "docker", "systemctl", "sleep"): + tool = root / "bin" / name + tool.write_text("""#!/bin/sh +printf '%s %s\n' "${0##*/}" "$*" >> "$TEST_LOG" +case "${0##*/}" in + sleep) exit 0 ;; + podman|docker) + [ "$(cat "$TEST_MODE")" != pull-fail ] || exit 1 + if [ -n "${DOCKER_CONFIG:-}" ]; then test -r "$DOCKER_CONFIG/config.json" || exit 1; fi ;; + systemctl) + [ "${1:-}" != --user ] || shift + case "$1" in + show) echo 0 ;; + is-active) [ "$(cat "$TEST_MODE")" != unhealthy ] ;; + esac ;; +esac +""") + tool.chmod(0o755) + env = dict(os.environ, PATH=str(root / "bin") + os.pathsep + os.environ["PATH"], MONITOR_UPDATE_CONF_DIR=str(root / "conf"), TEST_LOG=str(log), TEST_MODE=str(mode)) + repo = "registry.example/monitor/monitor-agent" + digest = "sha256:" + "a" * 64 + image_file = root / "agent.image" + state = root / "state" + auth = root / "auth file.json" + auth.write_text('{"auths":{}}') + + def configure(engine="podman", scope="system", policy="manual", explicit=True): + values = dict(DATA_DIR=str(root / "data"), IMAGE_REPO=repo, UNIT="monitor-agent.service", PUBKEY=str(pub), ENV_FILE=str(image_file), STATE_DIR=str(state), POLICY=policy, AUTHFILE=str(auth)) + if explicit: + values.update(ENGINE=engine, SERVICE_SCOPE=scope) + (root / "conf/agent.conf").write_text("".join(k + "=" + shlex.quote(v) + "\n" for k, v in values.items())) + image_file.write_text("IMAGE=old-image\n") + (state / "agent.installed").unlink(missing_ok=True) + log.write_text("") + mode.write_text("healthy") + + def offer(seq=1, tamper=False): + manifest = root / "data/update/offer.manifest" + manifest.write_text(json.dumps(dict(component="agent", image=repo, digest=digest, tag="v1.0.0", version="1.0.0", seq=seq), separators=(",", ":"))) + sig = subprocess.check_output(["openssl", "dgst", "-sha256", "-sign", str(key), str(manifest)]) + (root / "data/update/offer.sig").write_bytes(base64.b64encode(sig)) + if tamper: + manifest.write_text(manifest.read_text().replace("1.0.0", "9.0.0")) + + def invoke(command): + result = subprocess.run(["sh", str(SCRIPT), command, "agent"], env=env, capture_output=True, text=True) + status = json.loads((root / "data/update-status/status.json").read_text()) + return result, status + + configure(explicit=False) + offer() + result, status = invoke("apply") + assert result.returncode == 0 and status["state"] == "up_to_date", result.stderr + assert "systemctl restart" in log.read_text() and "podman pull --quiet --authfile" in log.read_text() + print("PASS legacy rootful Podman config") + + configure(scope="user", policy="auto") + offer() + result, status = invoke("check") + assert result.returncode == 0 and status["state"] == "up_to_date", result.stderr + assert "systemctl --user restart" in log.read_text() + assert image_file.read_text() == "IMAGE=" + repo + "@" + digest + "\n" + print("PASS rootless Podman auto policy and user service restart") + log.write_text("") + _, status = invoke("check") + assert status["state"] == "up_to_date" and not log.read_text() + print("PASS replayed manifest does not pull or restart") + + configure(engine="docker") + offer() + result, status = invoke("apply") + assert result.returncode == 0 and status["state"] == "up_to_date", result.stderr + assert "docker pull --quiet" in log.read_text() and "--authfile" not in log.read_text() + assert "systemctl --user" not in log.read_text() + print("PASS Docker auth config and system service restart") + + configure(scope="user") + offer(tamper=True) + result, status = invoke("apply") + assert result.returncode != 0 and status["state"] == "invalid" and not log.read_text() + print("PASS invalid signature never invokes engine/service") + + configure(scope="user") + offer() + mode.write_text("unhealthy") + result, status = invoke("apply") + assert result.returncode != 0 and status["state"] == "rolled_back" + assert image_file.read_text() == "IMAGE=old-image\n" + assert log.read_text().count("systemctl --user restart") == 2 + assert not (state / "agent.installed").exists() + print("PASS unhealthy image rolls back without advancing sequence") + + configure(engine="docker") + offer() + mode.write_text("pull-fail") + result, status = invoke("apply") + assert result.returncode != 0 and status["state"] == "failed" + assert image_file.read_text() == "IMAGE=old-image\n" and "systemctl" not in log.read_text() + print("PASS failed pull leaves active image unchanged")