From 390e0565d34d162e7b8a566ebf58c8b87cca56ab Mon Sep 17 00:00:00 2001 From: Zhan Ziyang Date: Sun, 16 Aug 2026 01:27:30 +0800 Subject: [PATCH] feat: backend native executor implement --- .gitignore | 3 +- RUNBOOK_NATIVE_BACKEND.md | 151 +++++ UPDATE_PLAN.md | 147 ++++- go.mod | 5 +- go.sum | 38 +- .../native_backend_executor_linux_test.go | 200 +++++++ internal/backendupdate/intents.go | 66 +++ internal/backendupdate/model.go | 58 ++ internal/backendupdate/operations.go | 393 +++++++++++++ internal/backendupdate/operations_test.go | 99 ++++ internal/backendupdate/progress.go | 20 + internal/backendupdate/restart.go | 146 +++++ internal/backendupdate/restart_test.go | 40 ++ internal/backendupdate/updater.go | 535 ++++++++++++++++++ internal/backendupdate/updater_test.go | 331 +++++++++++ internal/daemonapi/protocol.go | 26 + internal/daemonclient/client.go | 67 +++ internal/daemonserver/server.go | 199 +++++++ internal/daemonserver/server_test.go | 203 +++++++ internal/deploymentconfig/config.go | 242 ++++++++ internal/deploymentconfig/config_test.go | 159 ++++++ internal/filestore/store.go | 42 +- internal/filestore/store_test.go | 29 + internal/hostnginx/config.go | 155 +++++ internal/hostnginx/config_test.go | 55 ++ internal/hostnginx/controller.go | 245 ++++++++ internal/hostnginx/controller_test.go | 103 ++++ internal/nativebackendexecutor/executor.go | 404 +++++++++++++ .../nativebackendexecutor/executor_test.go | 430 ++++++++++++++ internal/nativebackendexecutor/operations.go | 307 ++++++++++ internal/runtimepaths/paths.go | 16 + internal/systemd/manager.go | 24 + internal/systemd/systemctl.go | 134 +++++ internal/systemd/systemctl_test.go | 50 ++ internal/updatepackage/backend_native.go | 483 ++++++++++++++++ internal/updatepackage/backend_native_test.go | 195 +++++++ internal/updatepackage/direct_native.go | 152 +++++ internal/updatepackage/direct_native_test.go | 88 +++ main.go | 251 +++++++- main_test.go | 111 ++++ packaging/etc/yms-daemon/yms-daemon.toml | 15 + packaging/systemd/yms-backend@.service | 20 + packaging/systemd/yms-daemon.service | 23 + packaging/tmpfiles.d/yms-daemon.conf | 1 + 44 files changed, 6430 insertions(+), 31 deletions(-) create mode 100644 RUNBOOK_NATIVE_BACKEND.md create mode 100644 integration/native_backend_executor_linux_test.go create mode 100644 internal/backendupdate/intents.go create mode 100644 internal/backendupdate/model.go create mode 100644 internal/backendupdate/operations.go create mode 100644 internal/backendupdate/operations_test.go create mode 100644 internal/backendupdate/progress.go create mode 100644 internal/backendupdate/restart.go create mode 100644 internal/backendupdate/restart_test.go create mode 100644 internal/backendupdate/updater.go create mode 100644 internal/backendupdate/updater_test.go create mode 100644 internal/daemonapi/protocol.go create mode 100644 internal/daemonclient/client.go create mode 100644 internal/daemonserver/server.go create mode 100644 internal/daemonserver/server_test.go create mode 100644 internal/deploymentconfig/config.go create mode 100644 internal/deploymentconfig/config_test.go create mode 100644 internal/hostnginx/config.go create mode 100644 internal/hostnginx/config_test.go create mode 100644 internal/hostnginx/controller.go create mode 100644 internal/hostnginx/controller_test.go create mode 100644 internal/nativebackendexecutor/executor.go create mode 100644 internal/nativebackendexecutor/executor_test.go create mode 100644 internal/nativebackendexecutor/operations.go create mode 100644 internal/runtimepaths/paths.go create mode 100644 internal/systemd/manager.go create mode 100644 internal/systemd/systemctl.go create mode 100644 internal/systemd/systemctl_test.go create mode 100644 internal/updatepackage/backend_native.go create mode 100644 internal/updatepackage/backend_native_test.go create mode 100644 internal/updatepackage/direct_native.go create mode 100644 internal/updatepackage/direct_native_test.go create mode 100644 main_test.go create mode 100644 packaging/etc/yms-daemon/yms-daemon.toml create mode 100644 packaging/systemd/yms-backend@.service create mode 100644 packaging/systemd/yms-daemon.service create mode 100644 packaging/tmpfiles.d/yms-daemon.conf diff --git a/.gitignore b/.gitignore index 723ef36..c37e21a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +yms-daemon \ No newline at end of file diff --git a/RUNBOOK_NATIVE_BACKEND.md b/RUNBOOK_NATIVE_BACKEND.md new file mode 100644 index 0000000..0fb003b --- /dev/null +++ b/RUNBOOK_NATIVE_BACKEND.md @@ -0,0 +1,151 @@ +# Native backend 首次试运行 + +本文只适用于已经安装 `yms-backend@.service`、使用宿主机 Nginx managed upstream、当前由旧 `yms.service` 或 `ymsback.service` 接流的开发环境。 + +## 1. 构建 ARM64 二进制 + +在 `yms-daemon` 源码目录执行: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o yms-daemon . +``` + +## 2. 安装文件 + +把构建结果和 `packaging` 目录传到服务器后,以 root 执行: + +```bash +install -m 0755 yms-daemon /usr/bin/yms-daemon +install -d -m 0755 /etc/yms-daemon +install -m 0640 packaging/etc/yms-daemon/yms-daemon.toml /etc/yms-daemon/yms-daemon.toml +install -m 0644 packaging/systemd/yms-daemon.service /etc/systemd/system/yms-daemon.service +install -m 0644 packaging/systemd/yms-backend@.service /etc/systemd/system/yms-backend@.service +install -m 0644 packaging/tmpfiles.d/yms-daemon.conf /etc/tmpfiles.d/yms-daemon.conf +systemd-tmpfiles --create /etc/tmpfiles.d/yms-daemon.conf +systemctl daemon-reload +systemctl enable --now yms-daemon.service +``` + +## 3. 更新前检查 + +```bash +systemctl status yms-daemon.service --no-pager -l +/usr/sbin/nginx -t +curl -fsS http://127.0.0.1:8081/yms/actuator/health +``` + +确认健康响应顶层 `status` 为 `UP`。第一次更新前不要手工停止 `ymsback.service`;daemon 需要读取当前接流端口和旧 unit 状态完成受控迁移。 + +## 4. 执行更新 + +客户 repack ZIP 模式: + +```bash +yms-daemon update --service backend -f /home/yms/tmp/<完整包名>.zip +``` + +ZIP 根目录必须包含: + +```text +artifact-selection.json + +``` + +daemon 只接受 `backendArtifacts` 中唯一满足以下精确值的条目: + +```text +artifactKind = BACKEND +type = native +selectedType = native +``` + +开发环境不需要打 ZIP。Jenkins 或交付人员先把完整 JAR 复制到 `/home/yms/tmp`,再执行: + +```bash +yms-daemon update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar +``` + +`-f` 与 `--native-jar` 必须且只能提供一个。daemon 不根据文件扩展名选择更新模式。直传 JAR 会被完整校验并计算 SHA-256,然后保存到: + +```text +/home/yms/lib/releases/direct//<原始JAR文件名> +``` + +目录名精确取完整 SHA-256 小写十六进制字符串的前 12 位。事务幂等身份和文件内容校验仍使用完整 SHA-256;12 位目录发生内容冲突时拒绝覆盖。 + +普通模式会持续输出更新过程,例如: + +```text +INFO Validating direct native backend JAR and computing SHA-256 +CREATED Created update transaction <事务ID> +VALIDATING Validating inactive native backend slot +PREPARED Installing backend JAR and binding the inactive slot +STARTING Starting native backend unit yms-backend@8080.service +STARTING Waiting up to 2m0s for Actuator health: http://127.0.0.1:8080/yms/actuator/health +STARTING Actuator health status is UP +SWITCHING Switching host Nginx backend traffic to port 8080 +VERIFYING Updating the active compatibility JAR link +DRAINING Draining previous backend unit for 5s +COMMITTED Native backend update committed +transaction=<事务ID> state=COMMITTED +``` + +Jenkins 只需要退出码、不需要过程和成功结果时,使用精确参数 `--quite`: + +```bash +yms-daemon update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar --quite +``` + +`--quite` 不写正常过程和成功结果;失败信息仍写入 stderr,并返回非零退出码。 + +最终成功结果为: + +```text +transaction=<事务ID> state=COMMITTED +``` + +另一个终端可以查看全过程: + +```bash +journalctl -u yms-daemon.service -f +``` + +## 5. 零停机重启 backend + +修改 `/home/yms/bin/env/yms.env` 等 backend 启动配置后,执行: + +```bash +yms-daemon restart --service backend +``` + +daemon 读取 `/home/yms/lib/glory-soft-yms.jar` 当前指向的精确 release,创建独立 restart 事务,将同一 JAR 绑定到非活动槽位,启动并完成 Actuator 健康检查后切流,等待 drain,再停止旧槽。当前 JAR 已经位于 release 目录时直接复用;兼容入口仍是普通文件时,先按内容身份导入 release 目录。restart 不重新上传 JAR,也不以历史 update 的内容幂等键阻止本次轮转。 + +静默执行: + +```bash +yms-daemon restart --service backend --quite +``` + +restart 进行中如果 daemon 重启,再次执行同一命令会恢复唯一未完成的 backend restart 事务。存在其他未完成 update 事务时,restart 拒绝创建新事务并返回该活动事务 ID。 + +## 6. 更新或重启后检查 + +```bash +systemctl show --no-pager \ + --property=Id \ + --property=LoadState \ + --property=ActiveState \ + ymsback.service \ + yms-backend@8080.service \ + yms-backend@8081.service + +readlink /home/yms/lib/glory-soft-yms.jar + +sed -n \ + '/# yms-update managed upstream begin/,/# yms-update managed upstream end/p' \ + /etc/nginx/nginx.conf + +curl -fsS http://127.0.0.1:8080/yms/actuator/health +``` + +第一次从当前现场状态更新成功后,预期 Nginx 接流 8080、`yms-backend@8080.service` 为 active、旧 `ymsback.service` 为 inactive,兼容入口指向 `/home/yms/lib/releases` 中的新 JAR。 diff --git a/UPDATE_PLAN.md b/UPDATE_PLAN.md index b53131d..ea25d1e 100644 --- a/UPDATE_PLAN.md +++ b/UPDATE_PLAN.md @@ -36,6 +36,18 @@ CI 构建一次镜像 native 过渡链路中,backend 保持同一次 CI 生成的 JAR 及其 SHA-256。frontend 保持同一次 CI 生成的源归档及其 SHA-256;当前 repack 会把该归档展开到客户 ZIP 的 `dist/`,因此客户侧最终以 manifest 逐文件声明的 `dist/...` 路径、长度和 SHA-256 为安装身份。 +开发环境 backend 支持显式直传 JAR,不要求 Jenkins 生成 repack ZIP: + +```text +yms-daemon update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar +``` + +`--native-jar` 与 repack 的 `-f` 互斥。CLI 通过 Unix Socket 的 `inputType` 精确传递 `native-jar` 或 `repack-zip`,daemon 不通过扩展名推断输入类型。直传 JAR 的文件名按不透明字符串处理,不解析版本;daemon 完整读取 JAR、计算 SHA-256,并以 `direct//<原始JAR文件名>` 作为 `/home/yms/lib/releases` 下的不可变相对路径。目录名精确截取完整小写十六进制 SHA-256 的前 12 位,事务幂等和内容校验仍使用完整 SHA-256,目录冲突时拒绝覆盖。 + +本地 Unix Socket 的 update 响应采用连续 JSON 消息:过程消息的 `kind` 精确为 `progress`,最终消息的 `kind` 精确为 `result`。CLI 普通模式实时输出校验、事务、槽位准备、systemd 启动、Actuator 健康检查、gateway 切流、drain、旧 unit 停止和提交阶段。精确参数 `--quite` 关闭正常过程与成功结果输出,错误仍写入 stderr,退出码语义不变;CLI 断开不取消服务端持久化事务。 + +backend native 的零停机重启命令精确为 `yms-daemon restart --service backend`,并支持 `--quite`。restart 读取当前兼容 JAR 指向的精确 release,创建独立事务,将同一 release 绑定到非活动槽,启动、健康检查、切流、drain 并停止旧槽;每次新调用都会执行一次真实槽位轮转,不使用历史 update 的内容幂等键。存在未完成 restart 事务时,同一命令恢复该事务;存在其他未完成事务时拒绝新建 restart。 + ## 2. 已确认的精确事实 ### 2.1 仓库与组件 @@ -96,6 +108,10 @@ docker pull --platform docker image save --platform ... ``` +当前镜像 tag 的实际示例为 `20260814-093609-d7ed70f0-v1.1.8.1`,末尾 `v1.1.8.1` 可以不存在。daemon 将完整 tag 作为不透明字符串透传,不拆分日期、提交号或业务版本,也不使用 tag 判定镜像身份;镜像身份仍以本次更新明确给出的 digest 为准。 + +当前 `archive_artifact` 已经分别保存 digest ref 形式的 `imageRef` 和原始 `imageTag`,但 repack 的 `artifact-selection.json` 只输出 `imageRef`,未输出 `imageTag`。最终 repack 必须利用这两个已保存值生成完整 tagged image ref,以该完整引用执行 `docker image save`,并在 daemon manifest 中直接给出该引用和独立的 `imageDigest`;daemon 不负责拼接仓库名和 tag,也不得从其中一个值推导另一个值。 + ### 2.4 当前前端镜像 `YMSwellClient/scripts/deploy/Dockerfile` 已经执行: @@ -184,6 +200,86 @@ YMS_FLYWAY_ENABLED=true 当前返回值没有整包 SHA-256。PC 下载完成后的强身份校验需要发布中台扩展整包 SHA-256,并与包列表或下载令牌响应一同返回;精确字段名在修改 `deploy` 前同时冻结。 +### 2.8 当前客户 `/home/yms` 目录 + +2026-08-15 提供的客户服务器目录结果确认以下现状: + +- `/home/yms/bin` 保存现有运维和更新脚本。 +- `/home/yms/bin/env` 保存现有环境配置。 +- `/home/yms/lib/releases` 保存 backend 历史 JAR。 +- `/home/yms/lib/glory-soft-yms.jar` 是指向 `/home/yms/lib/releases` 中当前 JAR 的软链接。 +- `/home/yms/client-releases` 保存 frontend 历史版本目录。 +- `/home/yms/client` 是指向 `/home/yms/client-releases` 中当前 frontend 版本的软链接。 +- `/home/yms/tmp` 已存在。 +- `/home/yms/deploy/deploy-sync.sh` 仍然存在于现场。 + +这表明当前 native backend 和 frontend 已经具有“版本存储目录 + 当前入口软链接”的目录基础。该事实不直接冻结 native 双槽的 systemd unit、槽位名称和切流方式;零停机执行器仍须保证两个并行实例分别引用明确版本,不能在运行期间只依赖一个全局当前软链接表达两个实例状态。 + +native backend 的 systemd 模板名称已冻结为: + +```text +/etc/systemd/system/yms-backend@.service +``` + +模板实例标识固定使用端口号,完整 unit 名称为: + +```text +yms-backend@8080.service +yms-backend@8081.service +``` + +两个并行实例对应的槽位 JAR 软链接路径固定为: + +```text +/home/yms/lib/glory-soft-yms-8080.jar +/home/yms/lib/glory-soft-yms-8081.jar +``` + +当前人工运维兼容入口固定保留 `/home/yms/lib/glory-soft-yms.jar`,并指向当前接流版本。daemon 不把该单一兼容入口同时作为两个槽位的版本身份。 + +当前客户服务器执行 `command -v systemctl` 的精确输出为 `/bin/systemctl`,该现场的 daemon 配置使用此绝对路径。其他服务器如果精确输出 `/usr/bin/systemctl`,由该服务器的本机配置明确记录 `/usr/bin/systemctl`;daemon 不在两个路径之间自行选择。 + +native backend 模板使用 `Type=simple`,不再调用 `yms-service.sh` 或其他启动脚本。冻结后的 `ExecStart` 为: + +```text +/usr/bin/java -Xms1024m -Xmx10240m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/yms/dump -XX:ErrorFile=/home/yms/log/hs_err_pid%p.log -XX:+ExitOnOutOfMemoryError -jar /home/yms/lib/glory-soft-yms-%i.jar --server.port=%i +``` + +模板继续读取现有 `/home/yms/bin/env/yms.env`,工作目录为 `/home/yms/lib`,stdout 和 stderr 进入 journal。模板中 systemd 对字面量 `%p` 需要写为 `%%p`,JVM 最终接收到的参数仍为 `-XX:ErrorFile=/home/yms/log/hs_err_pid%p.log`。`TimeoutStopSec` 固定为 `150min`,与应用 `spring.lifecycle.timeout-per-shutdown-phase` 的默认 `150m` 对齐,不能沿用旧 unit 的 `30` 秒强制停止上限。 + +RPM 安装阶段创建 `/home/yms/dump`,属主和用户组固定为 `root:root`,权限固定为 `0755`。RPM 同时安装 tmpfiles 配置,并在安装事务中执行对应的目录创建;unit 不通过 `ExecStartPre` 临时创建目录。 + +### 2.9 第一阶段本机配置 + +daemon 本机配置文件固定为 `/etc/yms-daemon/yms-daemon.toml`。第一阶段已经冻结并实现 backend native 配置: + +```toml +[backend] +type = "native" +release_dir = "/home/yms/lib/releases" +active_jar = "/home/yms/lib/glory-soft-yms.jar" +systemctl_path = "/bin/systemctl" + +[backend.slot.8080] +unit = "yms-backend@8080.service" +jar = "/home/yms/lib/glory-soft-yms-8080.jar" +health_endpoint = "http://127.0.0.1:8080/yms/actuator/health" + +[backend.slot.8081] +unit = "yms-backend@8081.service" +jar = "/home/yms/lib/glory-soft-yms-8081.jar" +health_endpoint = "http://127.0.0.1:8081/yms/actuator/health" +``` + +字段规则固定为: + +- table 和 key 必须与上例逐字一致;大小写不同、未知字段、未知槽位和重复字段全部拒绝。 +- `backend.type` 第一阶段只接受精确值 `native`;container 配置在其完整 table 和 key 冻结后扩展,daemon 不根据现场状态自动选择。 +- `release_dir`、`active_jar`、两个槽位的 `unit`、`jar` 和 `health_endpoint` 必须与上例完全相同。 +- `systemctl_path` 必须由现场明确填写干净的绝对路径;当前现场填写 `/bin/systemctl`,精确检查结果为 `/usr/bin/systemctl` 的服务器填写 `/usr/bin/systemctl`。 +- loader 不补默认值,不改写路径,不改写大小写,不根据端口拼接 unit、JAR 路径或健康地址。 +- RPM 安装示例文件位于 `packaging/etc/yms-daemon/yms-daemon.toml`;RPM spec 接入时将其安装到固定路径。 + ## 3. 总体架构 daemon 内部先选择服务器已经明确登记的组件执行器,再进入相同的事务、健康检查、切流和回滚流程: @@ -287,6 +383,20 @@ Jenkins / deploy 组件准备、健康检查、切流、验证和回滚实现必须完全复用。开发环境必须持续覆盖 native 和 container 两条执行路径,不能只验证目标态 Docker executor。 +### 3.4 YMS 在线上传兼容入口 + +现有 YMS 在线更新入口继续接收更新 ZIP,但不再解压更新包、查找 `deploy-sync.sh` 或由 JVM 执行更新。YMS 与宿主机 daemon 的更新包共享收件目录固定为: + +```text +/home/yms/tmp +``` + +native 部署下,YMS 和 daemon 直接使用该宿主机目录。Docker standalone 部署下,backend blue/green 容器把宿主机 `/home/yms/tmp` bind mount 到容器内同名绝对路径,避免容器路径与宿主机路径转换。backend blue/green 容器明确以 root 用户运行,不依赖基础镜像的默认用户。 + +该目录只承担更新包交接,不保存 daemon 的 SQLite、事务内部文件或日志。YMS 完成文件写入并持久化后再发布完整文件;daemon 只接管已经完整发布的普通文件,并在接管成功后创建或关联更新事务。daemon 不通过目录监听自动执行更新,更新仍须经过显式认证和事务提交。 + +`/home/yms/tmp` 的属主和用户组固定为 `root:root`,权限固定为 `0755`。内部子目录、临时文件命名、容量限制、保留时间和异常文件清理规则仍需冻结。现有客户环境已经使用 `/home/yms/tmp`,实施时必须先核对现存文件用途,不能清空或覆盖既有文件。 + ## 4. 进程和命令入口 RPM 全局安装单一二进制: @@ -378,6 +488,12 @@ yms-daemon update --service nodeSsr -f xxx.zip 开发环境由 Jenkins 构建步骤调用同一 `yms-daemon` CLI 快速触发更新。在线更新的具体参数尚未冻结;它必须进入同一个内部更新请求模型,但不强制先生成包含完整镜像层的 ZIP。 +第一阶段 backend native 命令已经实现。CLI 把相对 `-f` 路径解析为当前工作目录下的绝对路径,只通过 `/run/yms-daemon/yms-daemon.sock` 提交给 `serve`;CLI 进程不直接读取 SQLite、不调用 systemd、不改写 Nginx。当前只接受精确值 `--service backend`,其他 service 在对应执行器完成前明确拒绝。 + +当前 repack 兼容层读取 ZIP 根目录的 `artifact-selection.json`,并按 deploy 当前实际字段 `backendArtifacts[].artifactKind/type/selectedType/fileName/sha256` 选择唯一 native backend JAR。ZIP 条目、JSON table/key、重复字段、大小写变化、路径逃逸和符号链接全部严格拒绝。当前 deploy manifest 尚无文件长度字段,daemon 使用 ZIP 条目的实际解压长度并用 manifest SHA-256 校验内容;manifest 增加明确长度字段后再升级协议。 + +第一次从旧 native unit 迁移时,daemon 根据 Nginx managed upstream 的实际接流端口和 systemd 实际状态,在对应的旧 unit 与新模板 unit 中要求恰好一个正在运行。当前映射固定为 8080 对应 `yms.service`、8081 对应 `ymsback.service`;现场第一次更新从旧 `ymsback.service@8081` 切换到 `yms-backend@8080.service`,成功后停止旧 unit。后续只在两个 `yms-backend@.service` 实例之间轮换。 + ### 4.4 `rollback` ```bash @@ -918,6 +1034,7 @@ daemon 不通过文件名、扩展名、目录名称或正则表达式推断组 - `native` 使用 `files`,每个文件都必须声明 `path`、`size`、`sha256`。 - native frontend 的 `files` 必须枚举 repack 后客户 ZIP 的 `dist/` 下每个普通文件;示例中的两个文件不代表完整文件集合。 - `container` 使用 `artifacts`,并声明 `platform`、`path`、`size`、`sha256`、`imageRef`、`imageDigest`。 +- `imageRef` 必须是 repack 明确生成的完整 tagged image ref,其中 tag 使用发布归档保存的原始值,允许不包含末尾业务版本段;daemon 不拼接该引用。 - container `platform` 必须与服务器精确平台相同。 - `path` 必须是 ZIP 内明确的相对路径。 - `path` 禁止绝对路径、`..`、空路径、重复路径和符号链接。 @@ -1490,7 +1607,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: - [x] 确认第一版服务端 SQLite 使用单连接、rollback journal 和 `synchronous=EXTRA`。 - [ ] 冻结 frontend 旧资源兼容方案。 - [ ] 冻结本机组件运行类型配置 schema。 -- [ ] 冻结 native backend 双实例 systemd 模板和版本目录。 +- [x] 冻结 native backend 模板 `/etc/systemd/system/yms-backend@.service`、实例 `yms-backend@8080.service` 与 `yms-backend@8081.service`、release 目录和两个槽位 JAR 路径。 - [ ] 冻结 native frontend 双版本目录。 - [ ] 冻结 backend 蓝绿期间 Flyway、任务、消费者和 Session 约束。 - [ ] 冻结 gateway 配置目录、容器命名、network 和 labels。 @@ -1506,13 +1623,18 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: ### 阶段二:开发环境最小闭环 -- [ ] 实现 `serve`、`update`、`rollback` 和查询命令。 +- [x] 实现 `serve`、本地 Unix Socket 和 backend native `update` 命令。 +- [x] 实现 backend native 的 repack ZIP `-f` 与开发环境直传 JAR `--native-jar` 两种显式互斥输入,并让二者进入同一事务执行链。 +- [x] 实现 update Unix Socket 流式过程消息和 CLI 实时阶段输出,并提供精确静默参数 `--quite`。 +- [x] 实现 `yms-daemon restart --service backend` 的同版本零停机槽位轮转、流式进度和未完成事务恢复。 +- [ ] 实现 `rollback`、查询命令以及 frontend、nodeSsr、all 更新入口。 - [ ] 实现本地 Unix Socket、Linux advisory `flock` 和进程内更新互斥。 - [ ] 实现服务端禁用 CGO 的 SQLite 驱动、单连接配置、rollback journal 和 `synchronous=EXTRA` 启动校验。 - [ ] 实现服务端事务、步骤、幂等和 WSS 事件的 SQLite 持久化约束。 - [ ] 实现临时写入、文件 flush、摘要校验、原子 rename 和父目录 flush。 - [ ] 实现统一组件执行器和 gateway controller 接口。 -- [ ] 实现本机显式部署配置及 `type` 严格校验。 +- [x] 实现 backend native 本机 TOML 配置、逐字键树校验、`type` 严格校验和 8080/8081 槽位查询。 +- [ ] 在精确 schema 冻结后扩展 frontend、nodeSsr 和 container 本机配置。 - [ ] 实现 Docker Engine API 客户端和兼容性检查。 - [ ] 实现镜像 digest 校验。 - [ ] 实现 blue/green 统一生命周期。 @@ -1524,7 +1646,7 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: ### 阶段三:native/hybrid 与应用兼容改造 -- [ ] 实现 backend native systemd executor。 +- [x] 实现 backend native systemd executor 内核:不可变 JAR、非活动槽位绑定、systemd 启停、Actuator 健康检查、事务恢复和切流前补偿;实际双实例 unit 与槽位路径仍由冻结后的本机配置接入。 - [ ] 实现 frontend native directory executor。 - [ ] 实现 host Nginx gateway controller。 - [ ] 实现 native/container 混合 `all` 事务。 @@ -1619,6 +1741,17 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: 36. SQLite 状态提交和外部操作分步执行,SQLite 写事务中禁止等待 systemd、Docker、gateway、健康检查或 drain。 37. gateway 配置、systemd、Docker 和本地文件是恢复时的实际状态依据;SQLite 记录执行意图和历史,不能取代现场核对。 38. 服务端 SQLite 损坏时保留当前业务运行状态并拒绝新更新,不能用空数据库自动覆盖。 +39. YMS 与宿主机 daemon 的更新包共享收件目录固定为 `/home/yms/tmp`,属主和用户组固定为 `root:root`,权限固定为 `0755`;Docker backend blue/green 容器使用同名绝对路径 bind mount,并明确以 root 用户运行;该目录不保存 daemon 的 SQLite、事务内部文件或日志。 +40. native backend 模板固定为 `/etc/systemd/system/yms-backend@.service`,实例标识固定为 `8080` 和 `8081`,完整 unit 名称固定为 `yms-backend@8080.service` 和 `yms-backend@8081.service`,槽位 JAR 软链接固定为 `/home/yms/lib/glory-soft-yms-8080.jar` 和 `/home/yms/lib/glory-soft-yms-8081.jar`;当前接流版本的人工运维兼容入口固定保留 `/home/yms/lib/glory-soft-yms.jar`。 +41. 当前客户服务器的 systemctl 可执行文件绝对路径为 `/bin/systemctl`;其他服务器使用其本机明确配置的绝对路径,daemon 不自行尝试替代路径。 +42. native backend unit 使用 `Type=simple` 直接执行 `/usr/bin/java`,不调用启动脚本;堆转储目录固定为 `/home/yms/dump`,其属主和用户组固定为 `root:root`、权限固定为 `0755` 并由 RPM 创建;JVM 错误日志固定为 `/home/yms/log/hs_err_pid%p.log`,stdout 和 stderr 进入 journal,`TimeoutStopSec` 固定为 `150min`。 +43. daemon 本机配置文件固定为 `/etc/yms-daemon/yms-daemon.toml`,格式固定为 TOML;使用纯 Go 的 `github.com/pelletier/go-toml/v2` 严格解码,并在解码前逐字校验完整键树,拒绝大小写变化、未知字段、未知槽位和重复字段,保持 `CGO_ENABLED=0`。 +44. 第一阶段 backend native 配置精确使用 `[backend]`、`[backend.slot.8080]` 和 `[backend.slot.8081]`;key、必填规则和精确值以 2.9 节为准,loader 不提供配置默认值。 +45. daemon 服务端路径固定为:Unix Socket `/run/yms-daemon/yms-daemon.sock`、进程锁 `/run/yms-daemon/yms-daemon.lock`、SQLite `/var/lib/yms-daemon/yms-daemon.db`、事务工作目录 `/var/lib/yms-daemon/work`、本地日志 `/var/log/yms-daemon/yms-daemon.log`。 +46. 当前 native 过渡 gateway 固定管理 `/etc/nginx/nginx.conf` 中唯一一组 `# yms-update managed upstream begin/end` 标记,Nginx 可执行文件固定为 `/usr/sbin/nginx`,systemd unit 固定为 `nginx.service`;配置完整原子替换后执行 `/usr/sbin/nginx -t` 和 `systemctl reload`,任一步失败恢复替换前完整配置。 +47. 第一次旧 unit 运行态迁移固定映射为 8080=`yms.service`、8081=`ymsback.service`;Nginx 当前接流端口对应的旧 unit 和 `yms-backend@.service` 必须恰好一个处于运行态,否则拒绝更新。 +48. backend native 更新输入分为 `repack-zip` 和 `native-jar`;前者由 `-f` 提交,后者由 `--native-jar` 提交,二者互斥且不通过扩展名推断。直传 JAR 使用完整文件 SHA-256 作为幂等身份,在 `/home/yms/lib/releases` 下保存为 `direct//<原始JAR文件名>`;目录名精确取完整小写十六进制 SHA-256 的前 12 位,完整 SHA-256 继续用于幂等和内容校验,目录冲突时拒绝覆盖。直传 JAR 复用与 repack ZIP 相同的双槽、健康检查、切流、提交和补偿流程。 +49. backend native 零停机重启命令固定为 `yms-daemon restart --service backend`,可附加 `--quite`。restart 使用当前接流 release 创建独立事务并轮转到非活动槽,不调用 `systemctl restart`,也不重新上传 JAR;当前 JAR 已在 release 目录时直接复用,兼容入口仍是普通文件时按内容身份导入 release 目录。未完成 restart 由同一命令恢复,其他未完成事务阻止新 restart。 ## 18. 当前必须继续研讨的精确问题 @@ -1626,14 +1759,14 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: 2. frontend 旧资源采用版本命名空间、上一槽 fallback 还是组合方案。 3. backend 新旧版本并行时 Flyway 的单次执行方式。 4. backend 定时任务、PowerJob、消费者、Session 和缓存的实际行为。 -5. 本机组件运行类型配置的精确 schema 和固定路径。 -6. native backend 双实例 systemd unit、JAR 版本目录和端口的精确值。 +5. `/etc/yms-daemon/yms-daemon.toml` 中 frontend、nodeSsr 和 container 的精确 table、key 和必填规则。 +6. native backend 从现有 `/home/yms/bin/env/yms.env` 迁移到 `/home/yms/conf` 外挂配置的时机和兼容规则。 7. native frontend 双版本目录的精确值。 8. gateway 容器镜像、配置目录、Docker network、blue/green 容器名和 label 的精确值。 9. host Nginx 和 OpenResty 配置 reload 成功的确认方式。 10. SSE 和上传的最大 drain 时间与强制结束规则。 11. 三个组件的旧槽保留时间、磁盘清理和镜像清理规则。 -12. daemon OS 锁文件、服务端 SQLite、事务文件、上传暂存和日志的固定路径与权限。 +12. `/home/yms/tmp` 的内部子目录、容量限制、保留时间和异常文件清理规则。 13. Docker Engine 支持的最低版本和 API 版本范围。 14. RPM 是安装 Docker Engine、声明依赖还是只做运行前检查。 15. 现有 native 到 daemon 管理 native 双槽的迁移命令、路径、权限和失败恢复流程。 diff --git a/go.mod b/go.mod index 5d70689..2fc39be 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module yms-daemon -go 1.26 +go 1.26.5 require ( github.com/containerd/errdefs v1.0.0 @@ -10,6 +10,8 @@ require ( github.com/ncruces/go-sqlite3 v0.35.3 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/pelletier/go-toml/v2 v2.4.3 + golang.org/x/sys v0.47.0 ) require ( @@ -29,5 +31,4 @@ require ( go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect - golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index b8b1557..c2660d8 100644 --- a/go.sum +++ b/go.sum @@ -6,25 +6,25 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= @@ -41,27 +41,33 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/integration/native_backend_executor_linux_test.go b/integration/native_backend_executor_linux_test.go new file mode 100644 index 0000000..a992e19 --- /dev/null +++ b/integration/native_backend_executor_linux_test.go @@ -0,0 +1,200 @@ +//go:build linux + +package integration_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + + "yms-daemon/internal/filestore" + "yms-daemon/internal/nativebackendexecutor" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +func TestNativeBackendExecutorIntegration(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + databasePath := filepath.Join(root, "transactions.db") + store, err := transaction.OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("open transaction store: %v", err) + } + coordinator, err := transaction.NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create transaction coordinator: %v", err) + } + releaseStore, err := filestore.New(filepath.Join(root, "releases")) + if err != nil { + t.Fatalf("create native backend release store: %v", err) + } + + artifactContent := []byte("integrated native backend jar") + artifactPath := filepath.Join(root, "incoming", "glory-soft-yms.jar") + if err := os.MkdirAll(filepath.Dir(artifactPath), 0o750); err != nil { + t.Fatalf("create incoming directory: %v", err) + } + if err := os.WriteFile(artifactPath, artifactContent, 0o640); err != nil { + t.Fatalf("write incoming backend JAR: %v", err) + } + previousTarget := filepath.Join(root, "previous", "glory-soft-yms.jar") + if err := os.MkdirAll(filepath.Dir(previousTarget), 0o750); err != nil { + t.Fatalf("create previous release directory: %v", err) + } + if err := os.WriteFile(previousTarget, []byte("previous native backend jar"), 0o640); err != nil { + t.Fatalf("write previous backend JAR: %v", err) + } + slotJarPath := filepath.Join(root, "slots", "backend-green.jar") + if err := os.MkdirAll(filepath.Dir(slotJarPath), 0o750); err != nil { + t.Fatalf("create native backend slot directory: %v", err) + } + if err := os.Symlink(previousTarget, slotJarPath); err != nil { + t.Fatalf("create native backend slot link: %v", err) + } + + request := nativebackendexecutor.Request{ + ArtifactPath: artifactPath, + ArtifactIdentity: integrationIdentity(artifactContent), + ReleasePath: "glory-soft-yms-20260815.jar", + SlotJarPath: slotJarPath, + PreviousSlotTarget: previousTarget, + UnitName: "yms-green.service", + Port: 8081, + HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health", + } + units := &integrationUnitManager{unit: systemd.Unit{ + Name: request.UnitName, + LoadState: "loaded", + ActiveState: "inactive", + SubState: "dead", + }} + healthBody := `{"status":"UP","components":{"db":{"status":"UP","components":{"dorisDataSource":{"status":"UP"},"postgresqlDataSource":{"status":"UP"}}},"diskSpace":{"status":"UP"},"ping":{"status":"UP"},"redis":{"status":"UP"}}}` + httpClient := &http.Client{Transport: integrationRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewBufferString(healthBody)), + }, nil + })} + executor, err := nativebackendexecutor.New(store, coordinator, releaseStore, units, httpClient) + if err != nil { + t.Fatalf("create native backend executor: %v", err) + } + record, created, err := store.CreateTransaction(ctx, transaction.CreateRequest{ + ID: "native-backend-integration", + IdempotencyKey: "native-backend-integration-request", + Source: "integration-test", + Service: "backend", + }) + if err != nil || !created { + t.Fatalf("create native backend transaction: record=%+v created=%t err=%v", record, created, err) + } + + if err := executor.Run(ctx, record.ID, request); err != nil { + t.Fatalf("run native backend executor: %v", err) + } + installed, found, err := releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity) + if err != nil || !found { + t.Fatalf("inspect installed backend JAR: file=%+v found=%t err=%v", installed, found, err) + } + actualContent, err := os.ReadFile(installed.Path) + if err != nil || !bytes.Equal(actualContent, artifactContent) { + t.Fatalf("verify installed backend JAR: content=%q err=%v", actualContent, err) + } + actualTarget, err := os.Readlink(slotJarPath) + if err != nil || actualTarget != installed.Path { + t.Fatalf("verify native backend slot link: target=%q installed=%q err=%v", actualTarget, installed.Path, err) + } + if err := store.Close(); err != nil { + t.Fatalf("close transaction store: %v", err) + } + + reopened, err := transaction.OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("reopen transaction store: %v", err) + } + t.Cleanup(func() { _ = reopened.Close() }) + persisted, err := reopened.Transaction(ctx, record.ID) + if err != nil || persisted.State != transaction.StateSwitching { + t.Fatalf("unexpected persisted native transaction: record=%+v err=%v", persisted, err) + } + pending, err := reopened.PendingSteps(ctx, record.ID) + if err != nil || len(pending) != 0 { + t.Fatalf("unexpected persisted pending steps: steps=%+v err=%v", pending, err) + } + units.mu.Lock() + startCalls := units.startCalls + startedName := units.startedName + units.mu.Unlock() + if startCalls != 1 || startedName != request.UnitName { + t.Fatalf("unexpected integrated systemd calls: start=%d name=%s", startCalls, startedName) + } +} + +func integrationIdentity(content []byte) filestore.Identity { + digest := sha256.Sum256(content) + return filestore.Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} +} + +type integrationUnitManager struct { + mu sync.Mutex + unit systemd.Unit + startCalls int + stopCalls int + startedName string +} + +func (m *integrationUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { + m.mu.Lock() + defer m.mu.Unlock() + if name != m.unit.Name { + return systemd.Unit{}, systemd.ErrUnitNotFound + } + return m.unit, nil +} + +func (m *integrationUnitManager) Start(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if name != m.unit.Name { + return systemd.ErrUnitNotFound + } + m.startCalls++ + m.startedName = name + m.unit.ActiveState = "active" + m.unit.SubState = "running" + return nil +} + +func (m *integrationUnitManager) Stop(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if name != m.unit.Name { + return systemd.ErrUnitNotFound + } + m.stopCalls++ + m.unit.ActiveState = "inactive" + m.unit.SubState = "dead" + return nil +} + +type integrationRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f integrationRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.String() != "http://127.0.0.1:8081/yms/actuator/health" { + return nil, errors.New("unexpected native backend health endpoint: " + request.URL.String()) + } + return f(request) +} + +var _ systemd.Manager = (*integrationUnitManager)(nil) diff --git a/internal/backendupdate/intents.go b/internal/backendupdate/intents.go new file mode 100644 index 0000000..d175867 --- /dev/null +++ b/internal/backendupdate/intents.go @@ -0,0 +1,66 @@ +package backendupdate + +import ( + "encoding/json" + + "yms-daemon/internal/hostnginx" + "yms-daemon/internal/transaction" +) + +func gatewaySwitchIntent(request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot) transaction.StepIntent { + return stepIntent("backend.gateway.switch", "switch host Nginx backend upstream", struct { + BeforePort int `json:"beforePort"` + BeforeHash string `json:"beforeHash"` + AfterPort int `json:"afterPort"` + AfterHash string `json:"afterHash"` + }{before.ActivePort, snapshotDigest(before), after.ActivePort, snapshotDigest(after)}) +} + +func activeLinkIntent(request persistedRequest, installedPath string) transaction.StepIntent { + return stepIntent("backend.native.active-link", "replace backend compatibility link", struct { + Path string `json:"path"` + Before pathState `json:"before"` + InstalledPath string `json:"installedPath"` + }{request.ActiveJARPath, request.ActiveJARBefore, installedPath}) +} + +func stopPreviousUnitIntent(request persistedRequest) transaction.StepIntent { + return stepIntent("backend.previous-unit.stop", "stop previous backend unit after drain", struct { + Unit string `json:"unit"` + }{request.PreviousUnit}) +} + +func activeLinkRestoreIntent(request persistedRequest) transaction.StepIntent { + return stepIntent("backend.native.active-link.restore", "restore backend compatibility path", struct { + Path string `json:"path"` + Before pathState `json:"before"` + }{request.ActiveJARPath, request.ActiveJARBefore}) +} + +func gatewayRestoreIntent(request persistedRequest) transaction.StepIntent { + return stepIntent("backend.gateway.restore", "restore host Nginx backend upstream", struct { + Port int `json:"port"` + }{request.PreviousGatewayPort}) +} + +func stopTargetUnitIntent(request persistedRequest) transaction.StepIntent { + return stepIntent("backend.target-unit.stop", "stop compensated backend target unit", struct { + Unit string `json:"unit"` + }{request.TargetUnit}) +} + +func restoreTargetSlotIntent(request persistedRequest, installedPath string) transaction.StepIntent { + return stepIntent("backend.target-slot.restore", "restore compensated backend target slot", struct { + Path string `json:"path"` + InstalledPath string `json:"installedPath"` + PreviousTarget string `json:"previousTarget"` + }{request.TargetSlotJAR, installedPath, request.PreviousSlotTarget}) +} + +func stepIntent(key string, name string, value any) transaction.StepIntent { + content, err := json.Marshal(value) + if err != nil { + panic(err) + } + return transaction.StepIntent{Key: key, Name: name, Intent: content} +} diff --git a/internal/backendupdate/model.go b/internal/backendupdate/model.go new file mode 100644 index 0000000..faf791a --- /dev/null +++ b/internal/backendupdate/model.go @@ -0,0 +1,58 @@ +package backendupdate + +import "yms-daemon/internal/filestore" + +type updateInput struct { + IdempotencyKey string + InputType string + SourcePath string + SourceSHA256 string + CustomerCode string + VersionID string + ArtifactID int64 + ArtifactFileName string + ArtifactIdentity filestore.Identity + ReleasePath string + Materialize func(string) error +} + +type persistedRequest struct { + InputType string `json:"inputType"` + SourcePath string `json:"sourcePath"` + SourceSHA256 string `json:"sourceSHA256"` + CustomerCode string `json:"customerCode,omitempty"` + VersionID string `json:"versionId,omitempty"` + ArtifactID int64 `json:"artifactId,omitempty"` + ArtifactFileName string `json:"artifactFileName"` + ArtifactPath string `json:"artifactPath"` + ArtifactIdentity filestore.Identity `json:"artifactIdentity"` + ReleasePath string `json:"releasePath"` + TargetPort int `json:"targetPort"` + TargetUnit string `json:"targetUnit"` + TargetSlotJAR string `json:"targetSlotJar"` + TargetHealthEndpoint string `json:"targetHealthEndpoint"` + PreviousSlotTarget string `json:"previousSlotTarget"` + PreviousGatewayPort int `json:"previousGatewayPort"` + PreviousUnit string `json:"previousUnit"` + GatewayBeforePath string `json:"gatewayBeforePath"` + GatewayAfterPath string `json:"gatewayAfterPath"` + GatewayReceiptPath string `json:"gatewayReceiptPath"` + ActiveJARPath string `json:"activeJarPath"` + ActiveJARBefore pathState `json:"activeJarBefore"` +} + +type pathKind string + +const ( + pathKindAbsent pathKind = "absent" + pathKindRegular pathKind = "regular" + pathKindSymlink pathKind = "symlink" +) + +type pathState struct { + Kind pathKind `json:"kind"` + Target string `json:"target,omitempty"` + BackupPath string `json:"backupPath,omitempty"` + Identity filestore.Identity `json:"identity,omitempty"` + Mode uint32 `json:"mode,omitempty"` +} diff --git a/internal/backendupdate/operations.go b/internal/backendupdate/operations.go new file mode 100644 index 0000000..e40e35d --- /dev/null +++ b/internal/backendupdate/operations.go @@ -0,0 +1,393 @@ +package backendupdate + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "yms-daemon/internal/filestore" + "yms-daemon/internal/hostnginx" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +type gatewayOperation struct { + controller gatewayController + before hostnginx.Snapshot + after hostnginx.Snapshot + receiptPath string +} + +func (o *gatewayOperation) Apply(ctx context.Context) error { + if err := o.controller.Apply(ctx, o.after); err != nil { + return err + } + return writeImmutableFile(o.receiptPath, []byte(snapshotDigest(o.after)), 0o600) +} + +func (o *gatewayOperation) Inspect(context.Context) (transaction.Inspection, error) { + current, err := o.controller.Read() + if err != nil { + return transaction.Inspection{}, err + } + receipt, receiptErr := os.ReadFile(o.receiptPath) + if current.ActivePort == o.after.ActivePort && bytes.Equal(current.Content, o.after.Content) { + if receiptErr == nil && string(receipt) == snapshotDigest(o.after) { + return transaction.Inspection{Status: transaction.InspectionApplied, Result: gatewayResult(current)}, nil + } + if errors.Is(receiptErr, os.ErrNotExist) { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: gatewayResult(current)}, nil + } + if receiptErr != nil { + return transaction.Inspection{}, fmt.Errorf("read host Nginx switch receipt: %w", receiptErr) + } + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: gatewayResult(current)}, nil + } + if current.ActivePort == o.before.ActivePort && bytes.Equal(current.Content, o.before.Content) { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: gatewayResult(current)}, nil + } + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: gatewayResult(current)}, nil +} + +type pathOperation struct { + path string + before pathState + desired pathState +} + +func (o *pathOperation) Apply(context.Context) error { + return applyPathState(o.path, o.desired) +} + +func (o *pathOperation) Inspect(context.Context) (transaction.Inspection, error) { + desired, err := pathMatches(o.path, o.desired) + if err != nil { + return transaction.Inspection{}, err + } + if desired { + return transaction.Inspection{Status: transaction.InspectionApplied, Result: pathResult(o.path, o.desired)}, nil + } + before, err := pathMatches(o.path, o.before) + if err != nil { + return transaction.Inspection{}, err + } + if before { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: pathResult(o.path, o.before)}, nil + } + return transaction.Inspection{Status: transaction.InspectionUnknown}, nil +} + +type unitStopOperation struct { + units systemd.Manager + name string +} + +func (o *unitStopOperation) Apply(ctx context.Context) error { + return o.units.Stop(ctx, o.name) +} + +func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { + unit, err := o.units.Inspect(ctx, o.name) + if err != nil { + return transaction.Inspection{}, err + } + result, err := json.Marshal(unit) + if err != nil { + return transaction.Inspection{}, err + } + // systemd may leave a successfully stopped legacy service in failed when + // its tracked JVM exits with SIGTERM (status 143). Both states + // mean no process is active, which is the required result of this step. + if unit.ActiveState == "inactive" || unit.ActiveState == "failed" { + return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil + } + if unitRunning(unit) { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + } + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil +} + +func snapshotPath(path string, backupPath string) (pathState, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return pathState{Kind: pathKindAbsent}, nil + } + if err != nil { + return pathState{}, fmt.Errorf("inspect compatibility path %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return pathState{}, fmt.Errorf("read compatibility link %s: %w", path, err) + } + return pathState{Kind: pathKindSymlink, Target: target}, nil + } + if !info.Mode().IsRegular() { + return pathState{}, fmt.Errorf("compatibility path is neither a regular file nor symbolic link: %s", path) + } + identity, err := copyFileSnapshot(path, backupPath, info.Mode().Perm()) + if err != nil { + return pathState{}, err + } + return pathState{Kind: pathKindRegular, BackupPath: backupPath, Identity: identity, Mode: uint32(info.Mode().Perm())}, nil +} + +func applyPathState(path string, state pathState) error { + parent := filepath.Dir(path) + info, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("inspect path state parent %s: %w", parent, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path state parent is not a direct directory: %s", parent) + } + switch state.Kind { + case pathKindAbsent: + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove path state %s: %w", path, err) + } + return syncDirectory(parent) + case pathKindSymlink: + if !filepath.IsAbs(state.Target) { + return errors.New("path state symbolic link target must be absolute") + } + temporary := filepath.Join(parent, ".yms-daemon-link-"+rand.Text()) + if err := os.Symlink(state.Target, temporary); err != nil { + return fmt.Errorf("create temporary compatibility link: %w", err) + } + defer os.Remove(temporary) + if err := os.Rename(temporary, path); err != nil { + return fmt.Errorf("replace compatibility link %s: %w", path, err) + } + return syncDirectory(parent) + case pathKindRegular: + if err := verifyFileIdentity(state.BackupPath, state.Identity); err != nil { + return fmt.Errorf("verify compatibility file snapshot: %w", err) + } + return copyFileAtomic(state.BackupPath, path, os.FileMode(state.Mode)) + default: + return fmt.Errorf("unsupported path state kind: %q", state.Kind) + } +} + +func pathMatches(path string, state pathState) (bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return state.Kind == pathKindAbsent, nil + } + if err != nil { + return false, err + } + switch state.Kind { + case pathKindAbsent: + return false, nil + case pathKindSymlink: + if info.Mode()&os.ModeSymlink == 0 { + return false, nil + } + target, err := os.Readlink(path) + return target == state.Target, err + case pathKindRegular: + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || uint32(info.Mode().Perm()) != state.Mode { + return false, nil + } + if err := verifyFileIdentity(path, state.Identity); err != nil { + return false, nil + } + return true, nil + default: + return false, fmt.Errorf("unsupported path state kind: %q", state.Kind) + } +} + +func copyFileSnapshot(sourcePath string, destinationPath string, mode os.FileMode) (filestore.Identity, error) { + if err := os.MkdirAll(filepath.Dir(destinationPath), 0o750); err != nil { + return filestore.Identity{}, err + } + source, err := os.Open(sourcePath) + if err != nil { + return filestore.Identity{}, err + } + defer source.Close() + temporary, err := os.CreateTemp(filepath.Dir(destinationPath), ".snapshot-*") + if err != nil { + return filestore.Identity{}, err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return filestore.Identity{}, err + } + digest := sha256.New() + size, copyErr := io.Copy(io.MultiWriter(temporary, digest), source) + if copyErr != nil { + _ = temporary.Close() + return filestore.Identity{}, copyErr + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return filestore.Identity{}, err + } + if err := temporary.Close(); err != nil { + return filestore.Identity{}, err + } + if err := os.Rename(temporaryPath, destinationPath); err != nil { + return filestore.Identity{}, err + } + if err := syncDirectory(filepath.Dir(destinationPath)); err != nil { + return filestore.Identity{}, err + } + return filestore.Identity{Size: size, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil +} + +func copyFileAtomic(sourcePath string, destinationPath string, mode os.FileMode) error { + source, err := os.Open(sourcePath) + if err != nil { + return err + } + defer source.Close() + parent := filepath.Dir(destinationPath) + temporary, err := os.CreateTemp(parent, ".restore-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if _, err := io.Copy(temporary, source); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, destinationPath); err != nil { + return err + } + return syncDirectory(parent) +} + +func writeImmutableFile(path string, content []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if errors.Is(err, os.ErrExist) { + existing, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if !bytes.Equal(existing, content) { + return fmt.Errorf("immutable file already exists with different content: %s", path) + } + return nil + } + if err != nil { + return err + } + complete := false + defer func() { + if !complete { + _ = os.Remove(path) + } + }() + if _, err := file.Write(content); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + if err := syncDirectory(filepath.Dir(path)); err != nil { + return err + } + complete = true + return nil +} + +func readGatewaySnapshot(path string, port int) (hostnginx.Snapshot, error) { + content, err := os.ReadFile(path) + if err != nil { + return hostnginx.Snapshot{}, err + } + actual, err := hostnginx.ActiveBackendPort(content) + if err != nil { + return hostnginx.Snapshot{}, err + } + if actual != port { + return hostnginx.Snapshot{}, fmt.Errorf("persisted host Nginx snapshot port mismatch: got %d, want %d", actual, port) + } + return hostnginx.Snapshot{Content: content, ActivePort: port}, nil +} + +func verifyFileIdentity(path string, identity filestore.Identity) error { + if err := identity.Validate(); err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + digest := sha256.New() + size, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return err + } + if size != identity.Size || !strings.EqualFold(hex.EncodeToString(digest.Sum(nil)), identity.SHA256) { + return errors.New("file identity mismatch") + } + return nil +} + +func snapshotDigest(snapshot hostnginx.Snapshot) string { + digest := sha256.Sum256(snapshot.Content) + return hex.EncodeToString(digest[:]) +} + +func gatewayResult(snapshot hostnginx.Snapshot) json.RawMessage { + result, _ := json.Marshal(struct { + ActivePort int `json:"activePort"` + SHA256 string `json:"sha256"` + }{snapshot.ActivePort, snapshotDigest(snapshot)}) + return result +} + +func pathResult(path string, state pathState) json.RawMessage { + result, _ := json.Marshal(struct { + Path string `json:"path"` + State pathState `json:"state"` + }{path, state}) + return result +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + syncErr := file.Sync() + closeErr := file.Close() + return errors.Join(syncErr, closeErr) +} diff --git a/internal/backendupdate/operations_test.go b/internal/backendupdate/operations_test.go new file mode 100644 index 0000000..4f6efa5 --- /dev/null +++ b/internal/backendupdate/operations_test.go @@ -0,0 +1,99 @@ +package backendupdate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +func TestPathOperationReplacesRegularCompatibilityJarAndRestoresIt(t *testing.T) { + root := t.TempDir() + activePath := filepath.Join(root, "glory-soft-yms.jar") + original := []byte("original backend JAR") + if err := os.WriteFile(activePath, original, 0o644); err != nil { + t.Fatalf("write original compatibility JAR: %v", err) + } + before, err := snapshotPath(activePath, filepath.Join(root, "transaction", "active.before")) + if err != nil { + t.Fatalf("snapshot compatibility JAR: %v", err) + } + installedPath := filepath.Join(root, "releases", "new.jar") + if err := os.MkdirAll(filepath.Dir(installedPath), 0o750); err != nil { + t.Fatalf("create release directory: %v", err) + } + if err := os.WriteFile(installedPath, []byte("new backend JAR"), 0o640); err != nil { + t.Fatalf("write new backend JAR: %v", err) + } + + apply := &pathOperation{path: activePath, before: before, desired: pathState{Kind: pathKindSymlink, Target: installedPath}} + if err := apply.Apply(context.Background()); err != nil { + t.Fatalf("replace compatibility JAR with link: %v", err) + } + if target, err := os.Readlink(activePath); err != nil || target != installedPath { + t.Fatalf("unexpected compatibility link: target=%q err=%v", target, err) + } + + restore := &pathOperation{path: activePath, before: pathState{Kind: pathKindSymlink, Target: installedPath}, desired: before} + if err := restore.Apply(context.Background()); err != nil { + t.Fatalf("restore compatibility JAR: %v", err) + } + actual, err := os.ReadFile(activePath) + if err != nil || string(actual) != string(original) { + t.Fatalf("unexpected restored compatibility JAR: content=%q err=%v", actual, err) + } +} + +func TestResolveCurrentUnitSupportsFirstLegacyMigrationAndTemplateRotation(t *testing.T) { + tests := []struct { + name string + configured string + legacy string + want string + }{ + {name: "legacy", configured: "inactive", legacy: "activating", want: legacyUnit8081}, + {name: "template", configured: "active", legacy: "failed", want: "yms-backend@8081.service"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + units := &unitStateManager{units: map[string]systemd.Unit{ + "yms-backend@8081.service": {Name: "yms-backend@8081.service", LoadState: "loaded", ActiveState: test.configured}, + legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: test.legacy}, + }} + updater := &Updater{units: units} + actual, err := updater.resolveCurrentUnit(context.Background(), 8081, "yms-backend@8081.service") + if err != nil || actual != test.want { + t.Fatalf("unexpected current unit: unit=%q err=%v", actual, err) + } + }) + } +} + +func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { + units := &unitStateManager{units: map[string]systemd.Unit{ + legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "failed"}, + }} + operation := &unitStopOperation{units: units, name: legacyUnit8081} + inspection, err := operation.Inspect(context.Background()) + if err != nil { + t.Fatalf("inspect stopped legacy unit: %v", err) + } + if inspection.Status != transaction.InspectionApplied { + t.Fatalf("unexpected stopped legacy unit inspection: %+v", inspection) + } +} + +type unitStateManager struct { + units map[string]systemd.Unit +} + +func (m *unitStateManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { + return m.units[name], nil +} + +func (m *unitStateManager) Start(context.Context, string) error { return nil } + +func (m *unitStateManager) Stop(context.Context, string) error { return nil } diff --git a/internal/backendupdate/progress.go b/internal/backendupdate/progress.go new file mode 100644 index 0000000..18a371f --- /dev/null +++ b/internal/backendupdate/progress.go @@ -0,0 +1,20 @@ +package backendupdate + +import "yms-daemon/internal/transaction" + +// Progress is one live update event sent to the local CLI. It is observational +// only: delivery failure must not change the persisted update transaction. +type Progress struct { + TransactionID string + State transaction.State + Message string +} + +type ProgressReporter func(Progress) + +func operationLabel(inputType string) string { + if inputType == inputTypeCurrentRelease { + return "restart" + } + return "update" +} diff --git a/internal/backendupdate/restart.go b/internal/backendupdate/restart.go new file mode 100644 index 0000000..07a8a54 --- /dev/null +++ b/internal/backendupdate/restart.go @@ -0,0 +1,146 @@ +package backendupdate + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + + "yms-daemon/internal/transaction" + "yms-daemon/internal/updatepackage" +) + +// Restart performs a zero-downtime rotation with the exact release currently +// exposed by the compatibility JAR path. +func (u *Updater) Restart(ctx context.Context, report ProgressReporter) (transaction.Transaction, error) { + reportProgress(report, Progress{Message: "Resolving the current native backend release"}) + active, err := u.store.ActiveTransaction(ctx) + if err == nil { + if active.Service != serviceBackend { + return active, &transaction.ActiveTransactionError{TransactionID: active.ID} + } + var request persistedRequest + if decodeErr := decodePersistedRequest(active.Request, &request); decodeErr != nil { + return active, decodeErr + } + if request.InputType != inputTypeCurrentRelease { + return active, &transaction.ActiveTransactionError{TransactionID: active.ID} + } + input, inputErr := u.persistedRestartInput(request, active.IdempotencyKey) + if inputErr != nil { + return active, inputErr + } + return u.update(ctx, input, report) + } + if !errors.Is(err, transaction.ErrNotFound) { + return transaction.Transaction{}, err + } + + sourcePath, err := currentReleaseSource(u.config.Backend.ActiveJAR) + if err != nil { + return transaction.Transaction{}, err + } + jar, err := updatepackage.OpenDirectNativeJAR(sourcePath) + if err != nil { + return transaction.Transaction{}, err + } + releasePath, err := u.restartReleasePath(jar) + if err != nil { + return transaction.Transaction{}, err + } + restartID := rand.Text() + return u.update(ctx, updateInput{ + IdempotencyKey: serviceBackend + ":restart:" + restartID, + InputType: inputTypeCurrentRelease, + SourcePath: jar.Path, + SourceSHA256: jar.SHA256, + ArtifactFileName: jar.FileName, + ArtifactIdentity: jar.Identity, + ReleasePath: releasePath, + Materialize: jar.CopyArtifact, + }, report) +} + +func currentReleaseSource(activeJAR string) (string, error) { + info, err := os.Lstat(activeJAR) + if err != nil { + return "", fmt.Errorf("inspect active compatibility JAR %s: %w", activeJAR, err) + } + if info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + return activeJAR, nil + } + if info.Mode()&os.ModeSymlink == 0 { + return "", fmt.Errorf("active compatibility JAR is neither a direct regular file nor symbolic link: %s", activeJAR) + } + target, err := os.Readlink(activeJAR) + if err != nil { + return "", fmt.Errorf("read active compatibility JAR link %s: %w", activeJAR, err) + } + if !filepath.IsAbs(target) { + return "", fmt.Errorf("active compatibility JAR link target must be absolute: %s", target) + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("resolve active compatibility JAR target %s: %w", target, err) + } + return resolved, nil +} + +func (u *Updater) restartReleasePath(jar updatepackage.DirectNativeJAR) (string, error) { + resolvedReleaseDir, err := filepath.EvalSymlinks(u.config.Backend.ReleaseDir) + if err != nil { + return "", fmt.Errorf("resolve native backend release directory: %w", err) + } + relative, err := filepath.Rel(resolvedReleaseDir, jar.Path) + if err != nil { + return "", fmt.Errorf("compare current JAR with native backend release directory: %w", err) + } + if relative != "." && filepath.IsLocal(relative) { + return relative, nil + } + return filepath.Join("direct", jar.SHA256[:directReleaseDigestLength], jar.FileName), nil +} + +func (u *Updater) persistedRestartInput(request persistedRequest, idempotencyKey string) (updateInput, error) { + materialize, err := u.restartMaterializer(request) + if err != nil { + return updateInput{}, err + } + return updateInput{ + IdempotencyKey: idempotencyKey, + InputType: request.InputType, + SourcePath: request.SourcePath, + SourceSHA256: request.SourceSHA256, + ArtifactFileName: request.ArtifactFileName, + ArtifactIdentity: request.ArtifactIdentity, + ReleasePath: request.ReleasePath, + Materialize: materialize, + }, nil +} + +func (u *Updater) restartMaterializer(request persistedRequest) (func(string) error, error) { + if info, err := os.Lstat(request.ArtifactPath); err == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("persisted backend restart artifact is not a direct regular file: %s", request.ArtifactPath) + } + return func(string) error { + return errors.New("persisted backend restart artifact disappeared during resume") + }, nil + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("inspect persisted backend restart artifact: %w", err) + } + + paths := []string{request.SourcePath, filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)} + for _, sourcePath := range paths { + jar, err := updatepackage.OpenDirectNativeJAR(sourcePath) + if err != nil { + continue + } + if jar.SHA256 == request.SourceSHA256 && jar.Identity == request.ArtifactIdentity { + return jar.CopyArtifact, nil + } + } + return nil, fmt.Errorf("backend restart cannot recover artifact with identity %+v", request.ArtifactIdentity) +} diff --git a/internal/backendupdate/restart_test.go b/internal/backendupdate/restart_test.go new file mode 100644 index 0000000..0748f70 --- /dev/null +++ b/internal/backendupdate/restart_test.go @@ -0,0 +1,40 @@ +package backendupdate + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "yms-daemon/internal/daemonapi" + "yms-daemon/internal/transaction" +) + +func TestRestartRejectsUnfinishedBackendUpdate(t *testing.T) { + ctx := context.Background() + store, err := transaction.OpenStore(ctx, filepath.Join(t.TempDir(), "transactions.db")) + if err != nil { + t.Fatalf("open transaction store: %v", err) + } + defer store.Close() + request, err := json.Marshal(persistedRequest{InputType: daemonapi.InputTypeNativeJAR}) + if err != nil { + t.Fatalf("encode unfinished update request: %v", err) + } + active, _, err := store.CreateTransaction(ctx, transaction.CreateRequest{ + ID: "unfinished-backend-update", + IdempotencyKey: "backend:update:unfinished", + Source: sourceLocalCLI, + Service: serviceBackend, + Request: request, + }) + if err != nil { + t.Fatalf("create unfinished update transaction: %v", err) + } + updater := &Updater{store: store} + record, err := updater.Restart(ctx, nil) + if !errors.Is(err, transaction.ErrActiveExists) || record.ID != active.ID { + t.Fatalf("unexpected restart result with unfinished update: record=%+v err=%v", record, err) + } +} diff --git a/internal/backendupdate/updater.go b/internal/backendupdate/updater.go new file mode 100644 index 0000000..8516ce4 --- /dev/null +++ b/internal/backendupdate/updater.go @@ -0,0 +1,535 @@ +// Package backendupdate orchestrates one native backend update through commit or compensation. +package backendupdate + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "time" + + "yms-daemon/internal/daemonapi" + "yms-daemon/internal/deploymentconfig" + "yms-daemon/internal/filestore" + "yms-daemon/internal/hostnginx" + "yms-daemon/internal/nativebackendexecutor" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" + "yms-daemon/internal/updatepackage" +) + +const ( + serviceBackend = "backend" + sourceLocalCLI = "local-cli" + drainDuration = 5 * time.Second + directReleaseDigestLength = 12 + inputTypeCurrentRelease = "current-native-release" + legacyUnit8080 = "yms.service" + legacyUnit8081 = "ymsback.service" +) + +// Updater executes the current native backend contract on one server. +type Updater struct { + config deploymentconfig.Config + workRoot string + store *transaction.Store + coordinator *transaction.Coordinator + releaseStore *filestore.Store + units systemd.Manager + gateway gatewayController + executor nativeExecutor + logger *slog.Logger + drain time.Duration +} + +type gatewayController interface { + Read() (hostnginx.Snapshot, error) + Apply(context.Context, hostnginx.Snapshot) error +} + +type nativeExecutor interface { + Run(context.Context, string, nativebackendexecutor.Request) error +} + +// New creates the complete native backend update orchestrator. +func New( + config deploymentconfig.Config, + workRoot string, + store *transaction.Store, + coordinator *transaction.Coordinator, + releaseStore *filestore.Store, + units systemd.Manager, + gateway gatewayController, + httpClient *http.Client, + logger *slog.Logger, +) (*Updater, error) { + if err := config.Validate(); err != nil { + return nil, err + } + if !filepath.IsAbs(workRoot) || filepath.Clean(workRoot) != workRoot { + return nil, errors.New("backend update work root must be a clean absolute path") + } + if store == nil || coordinator == nil || releaseStore == nil || units == nil || gateway == nil { + return nil, errors.New("backend update dependencies are required") + } + if logger == nil { + logger = slog.Default() + } + executor, err := nativebackendexecutor.New(store, coordinator, releaseStore, units, httpClient) + if err != nil { + return nil, err + } + return &Updater{ + config: config, + workRoot: workRoot, + store: store, + coordinator: coordinator, + releaseStore: releaseStore, + units: units, + gateway: gateway, + executor: executor, + logger: logger, + drain: drainDuration, + }, nil +} + +// UpdateRepack applies one repack ZIP selected by an absolute local path. +func (u *Updater) UpdateRepack(ctx context.Context, packagePath string, report ProgressReporter) (transaction.Transaction, error) { + reportProgress(report, Progress{Message: "Validating repack ZIP"}) + updatePackage, err := updatepackage.OpenBackendNative(packagePath) + if err != nil { + return transaction.Transaction{}, err + } + defer updatePackage.Close() + reportProgress(report, Progress{Message: "Repack ZIP validated"}) + return u.update(ctx, updateInput{ + IdempotencyKey: serviceBackend + ":" + updatePackage.PackageSHA256, + InputType: daemonapi.InputTypeRepackZIP, + SourcePath: updatePackage.PackagePath, + SourceSHA256: updatePackage.PackageSHA256, + CustomerCode: updatePackage.CustomerCode, + VersionID: updatePackage.VersionID, + ArtifactID: updatePackage.ArtifactID, + ArtifactFileName: updatePackage.FileName, + ArtifactIdentity: updatePackage.Identity, + ReleasePath: updatePackage.FileName, + Materialize: updatePackage.ExtractArtifact, + }, report) +} + +// UpdateNativeJAR applies one JAR copied directly to the server. +func (u *Updater) UpdateNativeJAR(ctx context.Context, jarPath string, report ProgressReporter) (transaction.Transaction, error) { + reportProgress(report, Progress{Message: "Validating direct native backend JAR and computing SHA-256"}) + jar, err := updatepackage.OpenDirectNativeJAR(jarPath) + if err != nil { + return transaction.Transaction{}, err + } + reportProgress(report, Progress{Message: "Direct native backend JAR validated: sha256=" + jar.SHA256}) + return u.update(ctx, updateInput{ + IdempotencyKey: serviceBackend + ":" + jar.SHA256, + InputType: daemonapi.InputTypeNativeJAR, + SourcePath: jar.Path, + SourceSHA256: jar.SHA256, + ArtifactFileName: jar.FileName, + ArtifactIdentity: jar.Identity, + ReleasePath: filepath.Join("direct", jar.SHA256[:directReleaseDigestLength], jar.FileName), + Materialize: jar.CopyArtifact, + }, report) +} + +func (u *Updater) update(ctx context.Context, input updateInput, report ProgressReporter) (transaction.Transaction, error) { + existing, request, created, err := u.createOrResume(ctx, input) + if err != nil { + return transaction.Transaction{}, err + } + operation := operationLabel(input.InputType) + transactionMessage := "Resuming backend " + operation + " transaction" + if created { + transactionMessage = "Created backend " + operation + " transaction" + } + transactionMessage += " " + existing.ID + reportProgress(report, Progress{TransactionID: existing.ID, State: existing.State, Message: transactionMessage}) + if !created && existing.State.Terminal() { + return terminalResult(existing) + } + if err := os.MkdirAll(filepath.Dir(request.ArtifactPath), 0o750); err != nil { + return u.fail(ctx, existing.ID, fmt.Errorf("create backend transaction work directory: %w", err)) + } + reportProgress(report, Progress{TransactionID: existing.ID, State: existing.State, Message: "Staging backend artifact in transaction workspace"}) + if err := ensureTransactionArtifact(input, request); err != nil { + return u.fail(ctx, existing.ID, err) + } + + executorRequest := nativebackendexecutor.Request{ + ArtifactPath: request.ArtifactPath, + ArtifactIdentity: request.ArtifactIdentity, + ReleasePath: request.ReleasePath, + SlotJarPath: request.TargetSlotJAR, + PreviousSlotTarget: request.PreviousSlotTarget, + UnitName: request.TargetUnit, + Port: request.TargetPort, + HealthEndpoint: request.TargetHealthEndpoint, + Progress: func(state transaction.State, message string) { + reportProgress(report, Progress{TransactionID: existing.ID, State: state, Message: message}) + }, + } + switch existing.State { + case transaction.StateCreated, transaction.StateValidating, transaction.StatePrepared, transaction.StateStarting: + if err := u.executor.Run(ctx, existing.ID, executorRequest); err != nil { + return u.currentWithError(ctx, existing.ID, err) + } + case transaction.StateSwitching, transaction.StateVerifying, transaction.StateDraining: + case transaction.StateRollingBack: + if err := u.executor.Run(ctx, existing.ID, executorRequest); err != nil { + return u.currentWithError(ctx, existing.ID, err) + } + default: + return u.currentWithError(ctx, existing.ID, fmt.Errorf("backend update cannot resume transaction %s in state %s", existing.ID, existing.State)) + } + current, err := u.store.Transaction(ctx, existing.ID) + if err != nil { + return transaction.Transaction{}, err + } + if current.State.Terminal() { + return terminalResult(current) + } + if err := u.switchAndCommit(ctx, existing.ID, request, report); err != nil { + return u.currentWithError(ctx, existing.ID, err) + } + return u.store.Transaction(ctx, existing.ID) +} + +func terminalResult(record transaction.Transaction) (transaction.Transaction, error) { + if record.State == transaction.StateCommitted { + return record, nil + } + return record, fmt.Errorf("backend update transaction %s is terminal in state %s", record.ID, record.State) +} + +func (u *Updater) createOrResume(ctx context.Context, input updateInput) (transaction.Transaction, persistedRequest, bool, error) { + gatewayBefore, err := u.gateway.Read() + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + targetPort := otherPort(gatewayBefore.ActivePort) + targetSlot, err := u.config.Backend.SlotForPort(targetPort) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + currentSlot, err := u.config.Backend.SlotForPort(gatewayBefore.ActivePort) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + previousUnit, err := u.resolveCurrentUnit(ctx, gatewayBefore.ActivePort, currentSlot.Unit) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + previousSlotTarget, err := readOptionalSymlink(targetSlot.JAR) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + + transactionID := rand.Text() + transactionRoot := filepath.Join(u.workRoot, transactionID) + if err := os.MkdirAll(transactionRoot, 0o750); err != nil { + return transaction.Transaction{}, persistedRequest{}, false, fmt.Errorf("create backend transaction directory: %w", err) + } + gatewayAfterContent, err := hostnginx.RenderBackendPort(gatewayBefore.Content, targetPort) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + gatewayBeforePath := filepath.Join(transactionRoot, "gateway.before.conf") + gatewayAfterPath := filepath.Join(transactionRoot, "gateway.after.conf") + if err := writeImmutableFile(gatewayBeforePath, gatewayBefore.Content, 0o640); err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + if err := writeImmutableFile(gatewayAfterPath, gatewayAfterContent, 0o640); err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + + activeBefore, err := snapshotPath(u.config.Backend.ActiveJAR, filepath.Join(transactionRoot, "active-jar.before")) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + request := persistedRequest{ + InputType: input.InputType, + SourcePath: input.SourcePath, + SourceSHA256: input.SourceSHA256, + CustomerCode: input.CustomerCode, + VersionID: input.VersionID, + ArtifactID: input.ArtifactID, + ArtifactFileName: input.ArtifactFileName, + ArtifactPath: filepath.Join(transactionRoot, "backend.jar"), + ArtifactIdentity: input.ArtifactIdentity, + ReleasePath: input.ReleasePath, + TargetPort: targetPort, + TargetUnit: targetSlot.Unit, + TargetSlotJAR: targetSlot.JAR, + TargetHealthEndpoint: targetSlot.HealthEndpoint, + PreviousSlotTarget: previousSlotTarget, + PreviousGatewayPort: gatewayBefore.ActivePort, + PreviousUnit: previousUnit, + GatewayBeforePath: gatewayBeforePath, + GatewayAfterPath: gatewayAfterPath, + GatewayReceiptPath: filepath.Join(transactionRoot, "gateway.applied"), + ActiveJARPath: u.config.Backend.ActiveJAR, + ActiveJARBefore: activeBefore, + } + requestJSON, err := json.Marshal(request) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, fmt.Errorf("encode backend update request: %w", err) + } + record, created, err := u.store.CreateTransaction(ctx, transaction.CreateRequest{ + ID: transactionID, + IdempotencyKey: input.IdempotencyKey, + Source: sourceLocalCLI, + Service: serviceBackend, + Request: requestJSON, + }) + if err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + if created { + return record, request, true, nil + } + var persisted persistedRequest + if err := decodePersistedRequest(record.Request, &persisted); err != nil { + return transaction.Transaction{}, persistedRequest{}, false, err + } + if persisted.InputType != input.InputType || persisted.SourceSHA256 != input.SourceSHA256 { + return transaction.Transaction{}, persistedRequest{}, false, errors.New("persisted backend transaction input identity mismatch") + } + return record, persisted, false, nil +} + +func (u *Updater) switchAndCommit(ctx context.Context, transactionID string, request persistedRequest, report ProgressReporter) error { + operation := operationLabel(request.InputType) + before, err := readGatewaySnapshot(request.GatewayBeforePath, request.PreviousGatewayPort) + if err != nil { + return err + } + after, err := readGatewaySnapshot(request.GatewayAfterPath, request.TargetPort) + if err != nil { + return err + } + for { + record, err := u.store.Transaction(ctx, transactionID) + if err != nil { + return err + } + switch record.State { + case transaction.StateSwitching: + reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Switching host Nginx backend traffic to port %d", request.TargetPort)}) + gatewayOperation := &gatewayOperation{ + controller: u.gateway, + before: before, + after: after, + receiptPath: request.GatewayReceiptPath, + } + if _, err := u.coordinator.ExecuteStep(ctx, transactionID, gatewaySwitchIntent(request, before, after), gatewayOperation); err != nil { + return u.rollbackAfterPreparation(ctx, transactionID, request, before, after, err) + } + if _, err := u.store.Transition(ctx, transactionID, transaction.StateVerifying, "host Nginx now routes backend traffic to the healthy native slot"); err != nil { + return err + } + case transaction.StateVerifying: + reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Updating the active compatibility JAR link"}) + installedPath := filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath) + activeOperation := &pathOperation{ + path: request.ActiveJARPath, + before: request.ActiveJARBefore, + desired: pathState{Kind: pathKindSymlink, Target: installedPath}, + } + if _, err := u.coordinator.ExecuteStep(ctx, transactionID, activeLinkIntent(request, installedPath), activeOperation); err != nil { + return u.rollbackAfterPreparation(ctx, transactionID, request, before, after, err) + } + if _, err := u.store.Transition(ctx, transactionID, transaction.StateDraining, "backend compatibility link committed; previous unit draining"); err != nil { + return err + } + case transaction.StateDraining: + reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Draining previous backend unit for %s", u.drain)}) + if err := waitContext(ctx, u.drain); err != nil { + return err + } + reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Stopping previous backend unit " + request.PreviousUnit}) + stopOperation := &unitStopOperation{units: u.units, name: request.PreviousUnit} + if _, err := u.coordinator.ExecuteStep(ctx, transactionID, stopPreviousUnitIntent(request), stopOperation); err != nil { + return err + } + _, err = u.store.Transition(ctx, transactionID, transaction.StateCommitted, "native backend "+operation+" committed") + if err == nil { + reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateCommitted, Message: "Native backend " + operation + " committed"}) + } + return err + case transaction.StateCommitted: + reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Native backend " + operation + " already committed"}) + return nil + default: + return fmt.Errorf("backend commit cannot continue transaction %s in state %s", transactionID, record.State) + } + } +} + +func reportProgress(report ProgressReporter, progress Progress) { + if report != nil { + report(progress) + } +} + +func (u *Updater) rollbackAfterPreparation(ctx context.Context, transactionID string, request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot, cause error) error { + record, readErr := u.store.Transaction(ctx, transactionID) + if readErr != nil { + return errors.Join(cause, readErr) + } + if record.State != transaction.StateRollingBack { + if _, err := u.store.Transition(ctx, transactionID, transaction.StateRollingBack, "native backend post-start compensation started"); err != nil { + return errors.Join(cause, err) + } + } + + activeRestore := &pathOperation{ + path: request.ActiveJARPath, + before: pathState{Kind: pathKindSymlink, Target: filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)}, + desired: request.ActiveJARBefore, + } + _, activeErr := u.coordinator.ExecuteStep(ctx, transactionID, activeLinkRestoreIntent(request), activeRestore) + gatewayRestore := &gatewayOperation{ + controller: u.gateway, + before: after, + after: before, + receiptPath: request.GatewayReceiptPath + ".restore", + } + _, gatewayErr := u.coordinator.ExecuteStep(ctx, transactionID, gatewayRestoreIntent(request), gatewayRestore) + stopTarget := &unitStopOperation{units: u.units, name: request.TargetUnit} + _, stopErr := u.coordinator.ExecuteStep(ctx, transactionID, stopTargetUnitIntent(request), stopTarget) + installedPath := filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath) + previousSlotState := pathState{Kind: pathKindAbsent} + if request.PreviousSlotTarget != "" { + previousSlotState = pathState{Kind: pathKindSymlink, Target: request.PreviousSlotTarget} + } + slotRestore := &pathOperation{ + path: request.TargetSlotJAR, + before: pathState{Kind: pathKindSymlink, Target: installedPath}, + desired: previousSlotState, + } + _, slotErr := u.coordinator.ExecuteStep(ctx, transactionID, restoreTargetSlotIntent(request, installedPath), slotRestore) + if err := errors.Join(activeErr, gatewayErr, stopErr, slotErr); err != nil { + return errors.Join(cause, err) + } + _, transitionErr := u.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend post-start compensation completed") + return errors.Join(cause, transitionErr) +} + +func (u *Updater) resolveCurrentUnit(ctx context.Context, port int, configuredUnit string) (string, error) { + legacyUnit, err := legacyUnitForPort(port) + if err != nil { + return "", err + } + configured, err := u.units.Inspect(ctx, configuredUnit) + if err != nil { + return "", fmt.Errorf("inspect configured active-port unit %s: %w", configuredUnit, err) + } + legacy, err := u.units.Inspect(ctx, legacyUnit) + if err != nil { + return "", fmt.Errorf("inspect legacy active-port unit %s: %w", legacyUnit, err) + } + configuredRunning := unitRunning(configured) + legacyRunning := unitRunning(legacy) + if configuredRunning == legacyRunning { + return "", fmt.Errorf("backend port %d requires exactly one running unit, configured=%s(%s), legacy=%s(%s)", port, configuredUnit, configured.ActiveState, legacyUnit, legacy.ActiveState) + } + if configuredRunning { + return configuredUnit, nil + } + return legacyUnit, nil +} + +func (u *Updater) fail(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) { + _, transitionErr := u.store.Transition(ctx, transactionID, transaction.StateFailed, cause.Error()) + return u.currentWithError(ctx, transactionID, errors.Join(cause, transitionErr)) +} + +func (u *Updater) currentWithError(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) { + record, err := u.store.Transaction(ctx, transactionID) + return record, errors.Join(cause, err) +} + +func otherPort(port int) int { + if port == deploymentconfig.BackendPort8080 { + return deploymentconfig.BackendPort8081 + } + return deploymentconfig.BackendPort8080 +} + +func legacyUnitForPort(port int) (string, error) { + switch port { + case deploymentconfig.BackendPort8080: + return legacyUnit8080, nil + case deploymentconfig.BackendPort8081: + return legacyUnit8081, nil + default: + return "", fmt.Errorf("unsupported legacy backend port: %d", port) + } +} + +func unitRunning(unit systemd.Unit) bool { + return unit.ActiveState != "inactive" && unit.ActiveState != "failed" +} + +func readOptionalSymlink(path string) (string, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("inspect native backend target slot %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink == 0 { + return "", fmt.Errorf("native backend target slot is not a symbolic link: %s", path) + } + target, err := os.Readlink(path) + if err != nil { + return "", fmt.Errorf("read native backend target slot %s: %w", path, err) + } + return target, nil +} + +func ensureTransactionArtifact(input updateInput, request persistedRequest) error { + info, err := os.Lstat(request.ArtifactPath) + if errors.Is(err, os.ErrNotExist) { + return input.Materialize(request.ArtifactPath) + } + if err != nil { + return fmt.Errorf("inspect extracted native backend artifact: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("extracted native backend artifact is not a direct regular file") + } + return verifyFileIdentity(request.ArtifactPath, request.ArtifactIdentity) +} + +func decodePersistedRequest(content json.RawMessage, request *persistedRequest) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(request); err != nil { + return fmt.Errorf("decode persisted backend update request: %w", err) + } + return nil +} + +func waitContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/backendupdate/updater_test.go b/internal/backendupdate/updater_test.go new file mode 100644 index 0000000..4fd54e8 --- /dev/null +++ b/internal/backendupdate/updater_test.go @@ -0,0 +1,331 @@ +package backendupdate + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "yms-daemon/internal/deploymentconfig" + "yms-daemon/internal/hostnginx" + "yms-daemon/internal/nativebackendexecutor" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" + "yms-daemon/internal/updatepackage" +) + +func TestUpdaterMigratesLegacyBackendAndCommits(t *testing.T) { + testUpdaterMigratesLegacyBackendAndCommits(t, false) +} + +func TestUpdaterCommitsDirectNativeJAR(t *testing.T) { + testUpdaterMigratesLegacyBackendAndCommits(t, true) +} + +func testUpdaterMigratesLegacyBackendAndCommits(t *testing.T, direct bool) { + t.Helper() + ctx := context.Background() + root := t.TempDir() + store, err := transaction.OpenStore(ctx, filepath.Join(root, "transactions.db")) + if err != nil { + t.Fatalf("open transaction store: %v", err) + } + defer store.Close() + coordinator, err := transaction.NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create transaction coordinator: %v", err) + } + + releaseDir := filepath.Join(root, "releases") + activeJAR := filepath.Join(root, "lib", "glory-soft-yms.jar") + if err := os.MkdirAll(filepath.Dir(activeJAR), 0o750); err != nil { + t.Fatalf("create backend lib directory: %v", err) + } + if err := os.WriteFile(activeJAR, []byte("legacy backend JAR"), 0o644); err != nil { + t.Fatalf("write legacy backend JAR: %v", err) + } + config := deploymentconfig.Config{Backend: deploymentconfig.Backend{ + Type: deploymentconfig.BackendTypeNative, + ReleaseDir: releaseDir, + ActiveJAR: activeJAR, + Slot: deploymentconfig.BackendSlots{ + Port8080: deploymentconfig.BackendSlot{ + Unit: "yms-backend@8080.service", + JAR: filepath.Join(root, "lib", "glory-soft-yms-8080.jar"), + HealthEndpoint: "http://127.0.0.1:8080/yms/actuator/health", + }, + Port8081: deploymentconfig.BackendSlot{ + Unit: "yms-backend@8081.service", + JAR: filepath.Join(root, "lib", "glory-soft-yms-8081.jar"), + HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health", + }, + }, + }} + units := &updateUnitManager{units: map[string]systemd.Unit{ + "yms-backend@8080.service": {Name: "yms-backend@8080.service", LoadState: "loaded", ActiveState: "inactive"}, + "yms-backend@8081.service": {Name: "yms-backend@8081.service", LoadState: "loaded", ActiveState: "inactive"}, + legacyUnit8080: {Name: legacyUnit8080, LoadState: "loaded", ActiveState: "failed"}, + legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "activating"}, + }} + gateway := &memoryGateway{snapshot: hostnginx.Snapshot{Content: []byte(serverConfiguration8081), ActivePort: 8081}} + executor := &preparingExecutor{store: store, releaseDir: releaseDir, units: units} + updater := &Updater{ + config: config, + workRoot: filepath.Join(root, "work"), + store: store, + coordinator: coordinator, + units: units, + gateway: gateway, + executor: executor, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + drain: 0, + } + var record transaction.Transaction + var installedPath string + var progress []Progress + report := func(event Progress) { + progress = append(progress, event) + } + if direct { + jarPath := writeDirectBackendJAR(t, []byte("new backend JAR")) + jar, err := updatepackage.OpenDirectNativeJAR(jarPath) + if err != nil { + t.Fatalf("inspect direct native backend JAR: %v", err) + } + installedPath = filepath.Join(releaseDir, "direct", jar.SHA256[:12], jar.FileName) + record, err = updater.UpdateNativeJAR(ctx, jarPath, report) + } else { + packagePath := writeNativeBackendPackage(t, []byte("new backend JAR")) + installedPath = filepath.Join(releaseDir, "glory-soft-yms-test.jar") + record, err = updater.UpdateRepack(ctx, packagePath, report) + } + if err != nil { + t.Fatalf("update native backend: %v", err) + } + if record.State != transaction.StateCommitted || gateway.snapshot.ActivePort != 8080 { + t.Fatalf("unexpected committed update: record=%+v gateway=%+v", record, gateway.snapshot) + } + if target, err := os.Readlink(activeJAR); err != nil || target != installedPath { + t.Fatalf("unexpected compatibility link: target=%q installed=%q err=%v", target, installedPath, err) + } + if units.units[legacyUnit8081].ActiveState != "failed" || units.stopped != legacyUnit8081 { + t.Fatalf("legacy backend unit was not stopped: unit=%+v stopped=%s", units.units[legacyUnit8081], units.stopped) + } + if len(progress) == 0 || progress[len(progress)-1].State != transaction.StateCommitted { + t.Fatalf("missing committed update progress: %+v", progress) + } + if direct { + previousTransactionID := record.ID + restartSource, sourceErr := currentReleaseSource(activeJAR) + if sourceErr != nil { + t.Fatalf("resolve restart source: %v", sourceErr) + } + restartJAR, sourceErr := updatepackage.OpenDirectNativeJAR(restartSource) + if sourceErr != nil { + t.Fatalf("open restart source: %v", sourceErr) + } + restartReleasePath, sourceErr := updater.restartReleasePath(restartJAR) + if sourceErr != nil { + t.Fatalf("resolve restart release path: %v", sourceErr) + } + pendingRestart, _, created, sourceErr := updater.createOrResume(ctx, updateInput{ + IdempotencyKey: "backend:restart:test-resume", + InputType: inputTypeCurrentRelease, + SourcePath: restartJAR.Path, + SourceSHA256: restartJAR.SHA256, + ArtifactFileName: restartJAR.FileName, + ArtifactIdentity: restartJAR.Identity, + ReleasePath: restartReleasePath, + Materialize: restartJAR.CopyArtifact, + }) + if sourceErr != nil || !created { + t.Fatalf("create interrupted restart transaction: record=%+v created=%t err=%v", pendingRestart, created, sourceErr) + } + progress = nil + record, err = updater.Restart(ctx, report) + if err != nil { + t.Fatalf("restart native backend: %v", err) + } + if record.ID == previousTransactionID || record.ID != pendingRestart.ID || record.State != transaction.StateCommitted { + t.Fatalf("unexpected resumed restart transaction: previous=%s pending=%s record=%+v", previousTransactionID, pendingRestart.ID, record) + } + if gateway.snapshot.ActivePort != 8081 || units.units["yms-backend@8081.service"].ActiveState != "active" || units.units["yms-backend@8080.service"].ActiveState != "failed" { + t.Fatalf("restart did not rotate native backend slots: gateway=%+v units=%+v", gateway.snapshot, units.units) + } + if len(progress) == 0 || progress[len(progress)-1].Message != "Native backend restart committed" { + t.Fatalf("missing committed restart progress: %+v", progress) + } + } +} + +const serverConfiguration8081 = `http { + upstream yms-server { + # yms-update managed upstream begin + # server 10.11.1.117:8080 max_fails=1 fail_timeout=2s; + server 10.11.1.117:8081 max_fails=1 fail_timeout=2s; + # yms-update managed upstream end + } +} +` + +type memoryGateway struct { + snapshot hostnginx.Snapshot +} + +func (g *memoryGateway) Read() (hostnginx.Snapshot, error) { + return hostnginx.Snapshot{Content: append([]byte(nil), g.snapshot.Content...), ActivePort: g.snapshot.ActivePort}, nil +} + +func (g *memoryGateway) Apply(_ context.Context, snapshot hostnginx.Snapshot) error { + g.snapshot = hostnginx.Snapshot{Content: append([]byte(nil), snapshot.Content...), ActivePort: snapshot.ActivePort} + return nil +} + +type updateUnitManager struct { + units map[string]systemd.Unit + stopped string +} + +func (m *updateUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { + return m.units[name], nil +} + +func (m *updateUnitManager) Start(_ context.Context, name string) error { + unit := m.units[name] + unit.ActiveState = "active" + m.units[name] = unit + return nil +} + +func (m *updateUnitManager) Stop(_ context.Context, name string) error { + unit := m.units[name] + unit.ActiveState = "failed" + m.units[name] = unit + m.stopped = name + return nil +} + +type preparingExecutor struct { + store *transaction.Store + releaseDir string + units *updateUnitManager +} + +func (e *preparingExecutor) Run(ctx context.Context, transactionID string, request nativebackendexecutor.Request) error { + if err := os.MkdirAll(e.releaseDir, 0o750); err != nil { + return err + } + content, err := os.ReadFile(request.ArtifactPath) + if err != nil { + return err + } + installedPath := filepath.Join(e.releaseDir, request.ReleasePath) + if err := os.MkdirAll(filepath.Dir(installedPath), 0o750); err != nil { + return err + } + if err := os.WriteFile(installedPath, content, 0o640); err != nil { + return err + } + if err := os.Symlink(installedPath, request.SlotJarPath); err != nil { + return err + } + if err := e.units.Start(ctx, request.UnitName); err != nil { + return err + } + for _, state := range []transaction.State{ + transaction.StateValidating, + transaction.StatePrepared, + transaction.StateStarting, + transaction.StateSwitching, + } { + if _, err := e.store.Transition(ctx, transactionID, state, "test transition"); err != nil { + return err + } + } + return nil +} + +func writeNativeBackendPackage(t *testing.T, jar []byte) string { + t.Helper() + digest := sha256.Sum256(jar) + manifest := map[string]any{ + "customerCode": "customer-01", + "customerDisplayName": "Customer 01", + "versionId": "V1.1.8", + "items": []string{"deploy-sync.sh"}, + "backendArtifacts": []any{map[string]any{ + "id": int64(42), + "versionCode": "V1.1.8", + "artifactKind": "BACKEND", + "type": "native", + "selectedType": "native", + "platform": nil, + "fileName": "glory-soft-yms-test.jar", + "filePath": "/archive/glory-soft-yms-test.jar", + "sha256": hex.EncodeToString(digest[:]), + "imageRef": nil, + }}, + "frontendArtifacts": []any{}, + "nodeSsrArtifacts": []any{}, + "remark": nil, + } + manifestContent, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("encode artifact selection: %v", err) + } + packagePath := filepath.Join(t.TempDir(), "package.zip") + file, err := os.Create(packagePath) + if err != nil { + t.Fatalf("create update package: %v", err) + } + writer := zip.NewWriter(file) + for name, content := range map[string][]byte{ + "artifact-selection.json": manifestContent, + "glory-soft-yms-test.jar": jar, + } { + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create update package entry: %v", err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write update package entry: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close update package writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close update package: %v", err) + } + return packagePath +} + +func writeDirectBackendJAR(t *testing.T, content []byte) string { + t.Helper() + jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + file, err := os.Create(jarPath) + if err != nil { + t.Fatalf("create direct backend JAR: %v", err) + } + writer := zip.NewWriter(file) + entry, err := writer.Create("BOOT-INF/classes/application.properties") + if err != nil { + t.Fatalf("create direct backend JAR entry: %v", err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write direct backend JAR entry: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close direct backend JAR writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close direct backend JAR: %v", err) + } + return jarPath +} diff --git a/internal/daemonapi/protocol.go b/internal/daemonapi/protocol.go new file mode 100644 index 0000000..f424560 --- /dev/null +++ b/internal/daemonapi/protocol.go @@ -0,0 +1,26 @@ +// Package daemonapi defines the local Unix Socket request and response protocol. +package daemonapi + +const ( + OperationUpdate = "update" + OperationRestart = "restart" + InputTypeRepackZIP = "repack-zip" + InputTypeNativeJAR = "native-jar" + ResponseProgress = "progress" + ResponseResult = "result" +) + +type Request struct { + Operation string `json:"operation"` + Service string `json:"service"` + InputType string `json:"inputType"` + File string `json:"file"` +} + +type Response struct { + Kind string `json:"kind"` + TransactionID string `json:"transactionId,omitempty"` + State string `json:"state,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/daemonclient/client.go b/internal/daemonclient/client.go new file mode 100644 index 0000000..c221d66 --- /dev/null +++ b/internal/daemonclient/client.go @@ -0,0 +1,67 @@ +// Package daemonclient submits local CLI requests to the running daemon service. +package daemonclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "path/filepath" + + "yms-daemon/internal/daemonapi" +) + +func Update(ctx context.Context, socketPath string, service string, inputType string, file string, progress func(daemonapi.Response)) (daemonapi.Response, error) { + if !filepath.IsAbs(socketPath) || !filepath.IsAbs(file) { + return daemonapi.Response{}, errors.New("daemon socket and update file paths must be absolute") + } + request := daemonapi.Request{Operation: daemonapi.OperationUpdate, Service: service, InputType: inputType, File: file} + return submit(ctx, socketPath, request, progress) +} + +func Restart(ctx context.Context, socketPath string, service string, progress func(daemonapi.Response)) (daemonapi.Response, error) { + if !filepath.IsAbs(socketPath) { + return daemonapi.Response{}, errors.New("daemon socket path must be absolute") + } + request := daemonapi.Request{Operation: daemonapi.OperationRestart, Service: service} + return submit(ctx, socketPath, request, progress) +} + +func submit(ctx context.Context, socketPath string, request daemonapi.Request, progress func(daemonapi.Response)) (daemonapi.Response, error) { + dialer := net.Dialer{} + connection, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return daemonapi.Response{}, fmt.Errorf("connect to daemon service at %s: %w", socketPath, err) + } + defer connection.Close() + if err := json.NewEncoder(connection).Encode(request); err != nil { + return daemonapi.Response{}, fmt.Errorf("submit daemon request: %w", err) + } + if writer, ok := connection.(interface{ CloseWrite() error }); ok { + if err := writer.CloseWrite(); err != nil { + return daemonapi.Response{}, fmt.Errorf("finish daemon request: %w", err) + } + } + decoder := json.NewDecoder(connection) + decoder.DisallowUnknownFields() + for { + var response daemonapi.Response + if err := decoder.Decode(&response); err != nil { + return daemonapi.Response{}, fmt.Errorf("read daemon response: %w", err) + } + switch response.Kind { + case daemonapi.ResponseProgress: + if progress != nil { + progress(response) + } + case daemonapi.ResponseResult: + if response.Error != "" { + return response, errors.New(response.Error) + } + return response, nil + default: + return daemonapi.Response{}, fmt.Errorf("daemon returned unsupported response kind %q", response.Kind) + } + } +} diff --git a/internal/daemonserver/server.go b/internal/daemonserver/server.go new file mode 100644 index 0000000..0e450d4 --- /dev/null +++ b/internal/daemonserver/server.go @@ -0,0 +1,199 @@ +// Package daemonserver accepts local CLI requests over the frozen Unix Socket. +package daemonserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "yms-daemon/internal/backendupdate" + "yms-daemon/internal/daemonapi" + "yms-daemon/internal/transaction" +) + +const maximumRequestBytes = 1 << 20 + +type backendUpdater interface { + UpdateRepack(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error) + UpdateNativeJAR(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error) + Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error) +} + +// Server owns the local Unix Socket and dispatches requests to the transaction orchestrator. +type Server struct { + socketPath string + updater backendUpdater + logger *slog.Logger +} + +func New(socketPath string, updater backendUpdater, logger *slog.Logger) (*Server, error) { + if !filepath.IsAbs(socketPath) || filepath.Clean(socketPath) != socketPath { + return nil, errors.New("daemon Unix Socket path must be a clean absolute path") + } + if updater == nil { + return nil, errors.New("backend updater is required") + } + if logger == nil { + logger = slog.Default() + } + return &Server{socketPath: socketPath, updater: updater, logger: logger}, nil +} + +// Serve listens until ctx is canceled. Each accepted request owns its response connection. +func (s *Server) Serve(ctx context.Context) error { + if err := prepareSocketPath(s.socketPath); err != nil { + return err + } + listener, err := net.Listen("unix", s.socketPath) + if err != nil { + return fmt.Errorf("listen on daemon Unix Socket %s: %w", s.socketPath, err) + } + if err := os.Chmod(s.socketPath, 0o600); err != nil { + _ = listener.Close() + return fmt.Errorf("set daemon Unix Socket permissions: %w", err) + } + defer func() { + _ = listener.Close() + _ = os.Remove(s.socketPath) + }() + + s.logger.InfoContext(ctx, "daemon server listening", "socket", s.socketPath) + var connections sync.WaitGroup + defer connections.Wait() + go func() { + <-ctx.Done() + _ = listener.Close() + }() + for { + connection, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("accept daemon Unix Socket connection: %w", err) + } + connections.Add(1) + go func() { + defer connections.Done() + s.handle(ctx, connection) + }() + } +} + +func (s *Server) handle(ctx context.Context, connection net.Conn) { + defer connection.Close() + _ = connection.SetReadDeadline(time.Now().Add(10 * time.Second)) + request, err := decodeRequest(connection) + _ = connection.SetReadDeadline(time.Time{}) + if err != nil { + _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: err.Error()}) + return + } + if request.Service != "backend" { + _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "service must be backend"}) + return + } + progressWritable := true + report := func(progress backendupdate.Progress) { + if !progressWritable { + return + } + err := s.writeResponse(connection, daemonapi.Response{ + Kind: daemonapi.ResponseProgress, + TransactionID: progress.TransactionID, + State: string(progress.State), + Message: progress.Message, + }) + if err != nil { + progressWritable = false + s.logger.WarnContext(ctx, "write daemon update progress", "error", err) + } + } + var record transaction.Transaction + var updateErr error + switch request.Operation { + case daemonapi.OperationUpdate: + switch request.InputType { + case daemonapi.InputTypeRepackZIP: + record, updateErr = s.updater.UpdateRepack(ctx, request.File, report) + case daemonapi.InputTypeNativeJAR: + record, updateErr = s.updater.UpdateNativeJAR(ctx, request.File, report) + default: + _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "inputType must be repack-zip or native-jar"}) + return + } + case daemonapi.OperationRestart: + if request.InputType != "" || request.File != "" { + _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "restart does not accept inputType or file"}) + return + } + record, updateErr = s.updater.Restart(ctx, report) + default: + _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update or restart"}) + return + } + response := daemonapi.Response{Kind: daemonapi.ResponseResult, TransactionID: record.ID, State: string(record.State)} + if updateErr != nil { + response.Error = updateErr.Error() + s.logger.ErrorContext(ctx, "backend operation failed", "operation", request.Operation, "transaction_id", record.ID, "state", record.State, "error", updateErr) + } else { + s.logger.InfoContext(ctx, "backend operation completed", "operation", request.Operation, "transaction_id", record.ID, "state", record.State) + } + if err := s.writeResponse(connection, response); err != nil { + s.logger.ErrorContext(ctx, "write daemon operation result", "operation", request.Operation, "transaction_id", record.ID, "error", err) + } +} + +func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response) error { + return json.NewEncoder(connection).Encode(response) +} + +func decodeRequest(reader io.Reader) (daemonapi.Request, error) { + limited := io.LimitReader(reader, maximumRequestBytes+1) + decoder := json.NewDecoder(limited) + decoder.DisallowUnknownFields() + var request daemonapi.Request + if err := decoder.Decode(&request); err != nil { + return daemonapi.Request{}, fmt.Errorf("decode daemon request: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return daemonapi.Request{}, errors.New("daemon request contains multiple JSON values") + } + return daemonapi.Request{}, fmt.Errorf("decode daemon request trailing content: %w", err) + } + if strings.TrimSpace(request.Operation) != request.Operation || strings.TrimSpace(request.Service) != request.Service || strings.TrimSpace(request.InputType) != request.InputType || strings.TrimSpace(request.File) != request.File { + return daemonapi.Request{}, errors.New("daemon request fields must not contain surrounding whitespace") + } + return request, nil +} + +func prepareSocketPath(socketPath string) error { + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + return fmt.Errorf("create daemon Unix Socket directory: %w", err) + } + info, err := os.Lstat(socketPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect daemon Unix Socket path: %w", err) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("daemon Unix Socket path is occupied by a non-socket file: %s", socketPath) + } + if err := os.Remove(socketPath); err != nil { + return fmt.Errorf("remove stale daemon Unix Socket: %w", err) + } + return nil +} diff --git a/internal/daemonserver/server_test.go b/internal/daemonserver/server_test.go new file mode 100644 index 0000000..82b3fd9 --- /dev/null +++ b/internal/daemonserver/server_test.go @@ -0,0 +1,203 @@ +package daemonserver + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "yms-daemon/internal/backendupdate" + "yms-daemon/internal/daemonapi" + "yms-daemon/internal/daemonclient" + "yms-daemon/internal/transaction" +) + +func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) { + socketPath := shortSocketPath(t) + updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-01", State: transaction.StateCommitted}} + server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create daemon server: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + serveResult := make(chan error, 1) + go func() { serveResult <- server.Serve(ctx) }() + waitForSocket(t, socketPath, serveResult) + + packagePath := filepath.Join(t.TempDir(), "package.zip") + if err := os.WriteFile(packagePath, []byte("zip"), 0o600); err != nil { + t.Fatalf("write update package: %v", err) + } + var progress []daemonapi.Response + response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeRepackZIP, packagePath, func(event daemonapi.Response) { + progress = append(progress, event) + }) + if err != nil { + t.Fatalf("submit backend update: %v", err) + } + if response.TransactionID != updater.record.ID || response.State != string(transaction.StateCommitted) || updater.file != packagePath || updater.inputType != daemonapi.InputTypeRepackZIP { + t.Fatalf("unexpected update response or dispatch: response=%+v file=%s", response, updater.file) + } + if len(progress) != 1 || progress[0].Message != "test update progress" { + t.Fatalf("unexpected streamed update progress: %+v", progress) + } + + cancel() + select { + case err := <-serveResult: + if err != nil { + t.Fatalf("stop daemon server: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("daemon server did not stop") + } +} + +func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) { + socketPath := shortSocketPath(t) + updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-direct-01", State: transaction.StateCommitted}} + server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create daemon server: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serveResult := make(chan error, 1) + go func() { serveResult <- server.Serve(ctx) }() + waitForSocket(t, socketPath, serveResult) + + jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + if err := os.WriteFile(jarPath, []byte("jar"), 0o600); err != nil { + t.Fatalf("write direct native backend JAR: %v", err) + } + response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeNativeJAR, jarPath, nil) + if err != nil { + t.Fatalf("submit direct native backend update: %v", err) + } + if response.TransactionID != updater.record.ID || updater.file != jarPath || updater.inputType != daemonapi.InputTypeNativeJAR { + t.Fatalf("unexpected direct update response or dispatch: response=%+v file=%s inputType=%s", response, updater.file, updater.inputType) + } +} + +func TestServerReturnsTransactionFailure(t *testing.T) { + socketPath := shortSocketPath(t) + updater := &fakeUpdater{ + record: transaction.Transaction{ID: "transaction-02", State: transaction.StateRolledBack}, + err: errors.New("health check failed"), + } + server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create daemon server: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serveResult := make(chan error, 1) + go func() { serveResult <- server.Serve(ctx) }() + waitForSocket(t, socketPath, serveResult) + + packagePath := filepath.Join(t.TempDir(), "package.zip") + response, err := daemonclient.Update(context.Background(), socketPath, "backend", daemonapi.InputTypeRepackZIP, packagePath, nil) + if err == nil || response.TransactionID != updater.record.ID || response.State != string(transaction.StateRolledBack) { + t.Fatalf("unexpected failed update response: response=%+v err=%v", response, err) + } +} + +func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) { + socketPath := shortSocketPath(t) + updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-restart-01", State: transaction.StateCommitted}} + server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create daemon server: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serveResult := make(chan error, 1) + go func() { serveResult <- server.Serve(ctx) }() + waitForSocket(t, socketPath, serveResult) + + var progress []daemonapi.Response + response, err := daemonclient.Restart(context.Background(), socketPath, "backend", func(event daemonapi.Response) { + progress = append(progress, event) + }) + if err != nil { + t.Fatalf("submit backend restart: %v", err) + } + if response.TransactionID != updater.record.ID || updater.operation != daemonapi.OperationRestart { + t.Fatalf("unexpected restart response or dispatch: response=%+v operation=%s", response, updater.operation) + } + if len(progress) != 1 || progress[0].Message != "test restart progress" { + t.Fatalf("unexpected streamed restart progress: %+v", progress) + } +} + +func shortSocketPath(t *testing.T) string { + t.Helper() + directory, err := os.MkdirTemp("", "yd-") + if err != nil { + t.Fatalf("create short Unix Socket directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + return filepath.Join(directory, "daemon.sock") +} + +func waitForSocket(t *testing.T, socketPath string, serveResult <-chan error) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + select { + case err := <-serveResult: + if errors.Is(err, syscall.EPERM) { + t.Skip("Unix Socket creation is not permitted by the test sandbox") + } + t.Fatalf("daemon server stopped before creating Unix Socket: %v", err) + default: + } + info, err := os.Lstat(socketPath) + if err == nil && info.Mode()&os.ModeSocket != 0 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("daemon Unix Socket was not created: %s", socketPath) +} + +type fakeUpdater struct { + record transaction.Transaction + err error + file string + inputType string + operation string +} + +func (u *fakeUpdater) UpdateRepack(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) { + u.file = file + u.inputType = daemonapi.InputTypeRepackZIP + u.operation = daemonapi.OperationUpdate + if report != nil { + report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test update progress"}) + } + return u.record, u.err +} + +func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report backendupdate.ProgressReporter) (transaction.Transaction, error) { + u.file = file + u.inputType = daemonapi.InputTypeNativeJAR + u.operation = daemonapi.OperationUpdate + if report != nil { + report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test update progress"}) + } + return u.record, u.err +} + +func (u *fakeUpdater) Restart(_ context.Context, report backendupdate.ProgressReporter) (transaction.Transaction, error) { + u.operation = daemonapi.OperationRestart + if report != nil { + report(backendupdate.Progress{TransactionID: u.record.ID, State: transaction.StateStarting, Message: "test restart progress"}) + } + return u.record, u.err +} diff --git a/internal/deploymentconfig/config.go b/internal/deploymentconfig/config.go new file mode 100644 index 0000000..df8cd38 --- /dev/null +++ b/internal/deploymentconfig/config.go @@ -0,0 +1,242 @@ +// Package deploymentconfig loads the daemon's explicit local deployment configuration. +// It never infers deployment type, executable paths, unit names, slot paths, or endpoints. +package deploymentconfig + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +const ( + // DefaultPath is the only default location used by the daemon entrypoint. + DefaultPath = "/etc/yms-daemon/yms-daemon.toml" + + BackendTypeNative = "native" + + BackendPort8080 = 8080 + BackendPort8081 = 8081 +) + +const ( + nativeReleaseDir = "/home/yms/lib/releases" + nativeActiveJAR = "/home/yms/lib/glory-soft-yms.jar" + nativeUnit8080 = "yms-backend@8080.service" + nativeUnit8081 = "yms-backend@8081.service" + nativeSlotJAR8080 = "/home/yms/lib/glory-soft-yms-8080.jar" + nativeSlotJAR8081 = "/home/yms/lib/glory-soft-yms-8081.jar" + nativeHealthURL8080 = "http://127.0.0.1:8080/yms/actuator/health" + nativeHealthURL8081 = "http://127.0.0.1:8081/yms/actuator/health" +) + +// Config is the complete local deployment configuration currently understood by the daemon. +type Config struct { + Backend Backend `toml:"backend"` +} + +// Backend describes the explicitly selected backend runtime and its native blue/green slots. +type Backend struct { + Type string `toml:"type"` + ReleaseDir string `toml:"release_dir"` + ActiveJAR string `toml:"active_jar"` + SystemctlPath string `toml:"systemctl_path"` + Slot BackendSlots `toml:"slot"` +} + +// BackendSlots lists the only native backend ports supported by the current deployment contract. +type BackendSlots struct { + Port8080 BackendSlot `toml:"8080"` + Port8081 BackendSlot `toml:"8081"` +} + +// BackendSlot contains values that are passed to the native backend executor without derivation. +type BackendSlot struct { + Unit string `toml:"unit"` + JAR string `toml:"jar"` + HealthEndpoint string `toml:"health_endpoint"` +} + +// Load opens path, performs strict TOML decoding, and validates the native backend contract. +func Load(path string) (Config, error) { + if err := validateAbsolutePath("deployment configuration", path); err != nil { + return Config{}, err + } + + file, err := os.Open(path) + if err != nil { + return Config{}, fmt.Errorf("open deployment configuration %s: %w", path, err) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return Config{}, fmt.Errorf("inspect deployment configuration %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return Config{}, fmt.Errorf("deployment configuration is not a regular file: %s", path) + } + + document, err := io.ReadAll(file) + if err != nil { + return Config{}, fmt.Errorf("read deployment configuration %s: %w", path, err) + } + if err := validateExactDocumentKeys(document); err != nil { + return Config{}, fmt.Errorf("decode deployment configuration %s: %w", path, err) + } + + var config Config + if err := toml.NewDecoder(bytes.NewReader(document)).DisallowUnknownFields().Decode(&config); err != nil { + return Config{}, fmt.Errorf("decode deployment configuration %s: %w", path, err) + } + if err := config.Validate(); err != nil { + return Config{}, fmt.Errorf("validate deployment configuration %s: %w", path, err) + } + return config, nil +} + +func validateExactDocumentKeys(document []byte) error { + var root map[string]any + if err := toml.Unmarshal(document, &root); err != nil { + return err + } + if err := rejectUnknownKeys(root, "", "backend"); err != nil { + return err + } + backend, err := exactTable(root, "", "backend") + if err != nil { + return err + } + if err := rejectUnknownKeys(backend, "backend", "type", "release_dir", "active_jar", "systemctl_path", "slot"); err != nil { + return err + } + slots, err := exactTable(backend, "backend", "slot") + if err != nil { + return err + } + if err := rejectUnknownKeys(slots, "backend.slot", "8080", "8081"); err != nil { + return err + } + for _, port := range []string{"8080", "8081"} { + slot, err := exactTable(slots, "backend.slot", port) + if err != nil { + return err + } + if err := rejectUnknownKeys(slot, "backend.slot."+port, "unit", "jar", "health_endpoint"); err != nil { + return err + } + } + return nil +} + +func exactTable(table map[string]any, parent string, key string) (map[string]any, error) { + value, found := table[key] + field := key + if parent != "" { + field = parent + "." + key + } + if !found { + return nil, fmt.Errorf("%s table is required", field) + } + nested, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s must be a table", field) + } + return nested, nil +} + +func rejectUnknownKeys(table map[string]any, parent string, allowed ...string) error { + known := make(map[string]struct{}, len(allowed)) + for _, key := range allowed { + known[key] = struct{}{} + } + keys := make([]string, 0, len(table)) + for key := range table { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if _, found := known[key]; found { + continue + } + field := key + if parent != "" { + field = parent + "." + key + } + return fmt.Errorf("unknown deployment configuration field %s", field) + } + return nil +} + +// Validate rejects incomplete or altered local deployment identifiers. +func (c Config) Validate() error { + if c.Backend.Type != BackendTypeNative { + return fmt.Errorf("backend.type must be %q", BackendTypeNative) + } + if c.Backend.ReleaseDir != nativeReleaseDir { + return fmt.Errorf("backend.release_dir must be %q", nativeReleaseDir) + } + if c.Backend.ActiveJAR != nativeActiveJAR { + return fmt.Errorf("backend.active_jar must be %q", nativeActiveJAR) + } + if err := validateAbsolutePath("backend.systemctl_path", c.Backend.SystemctlPath); err != nil { + return err + } + if err := validateSlot("backend.slot.8080", c.Backend.Slot.Port8080, nativeUnit8080, nativeSlotJAR8080, nativeHealthURL8080); err != nil { + return err + } + if err := validateSlot("backend.slot.8081", c.Backend.Slot.Port8081, nativeUnit8081, nativeSlotJAR8081, nativeHealthURL8081); err != nil { + return err + } + return nil +} + +// Slot returns the exact configuration for one supported backend port. +func (b Backend) SlotForPort(port int) (BackendSlot, error) { + switch port { + case BackendPort8080: + return b.Slot.Port8080, nil + case BackendPort8081: + return b.Slot.Port8081, nil + default: + return BackendSlot{}, fmt.Errorf("unsupported native backend port: %d", port) + } +} + +func validateSlot(field string, slot BackendSlot, unit string, jar string, endpoint string) error { + if slot.Unit != unit { + return fmt.Errorf("%s.unit must be %q", field, unit) + } + if slot.JAR != jar { + return fmt.Errorf("%s.jar must be %q", field, jar) + } + if slot.HealthEndpoint != endpoint { + return fmt.Errorf("%s.health_endpoint must be %q", field, endpoint) + } + return nil +} + +func validateAbsolutePath(field string, value string) error { + if value == "" { + return fmt.Errorf("%s is required", field) + } + if strings.TrimSpace(value) != value { + return fmt.Errorf("%s must not contain surrounding whitespace", field) + } + if !filepath.IsAbs(value) { + return fmt.Errorf("%s must be an absolute path", field) + } + if filepath.Clean(value) != value { + return fmt.Errorf("%s must be a clean absolute path", field) + } + if strings.ContainsRune(value, '\x00') { + return errors.New(field + " contains a NUL byte") + } + return nil +} diff --git a/internal/deploymentconfig/config_test.go b/internal/deploymentconfig/config_test.go new file mode 100644 index 0000000..bf02564 --- /dev/null +++ b/internal/deploymentconfig/config_test.go @@ -0,0 +1,159 @@ +package deploymentconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const validNativeConfig = `[backend] +type = "native" +release_dir = "/home/yms/lib/releases" +active_jar = "/home/yms/lib/glory-soft-yms.jar" +systemctl_path = "/bin/systemctl" + +[backend.slot.8080] +unit = "yms-backend@8080.service" +jar = "/home/yms/lib/glory-soft-yms-8080.jar" +health_endpoint = "http://127.0.0.1:8080/yms/actuator/health" + +[backend.slot.8081] +unit = "yms-backend@8081.service" +jar = "/home/yms/lib/glory-soft-yms-8081.jar" +health_endpoint = "http://127.0.0.1:8081/yms/actuator/health" +` + +func TestLoadValidNativeConfiguration(t *testing.T) { + path := writeConfig(t, validNativeConfig) + + config, err := Load(path) + if err != nil { + t.Fatalf("load native deployment configuration: %v", err) + } + if config.Backend.Type != BackendTypeNative || config.Backend.SystemctlPath != "/bin/systemctl" { + t.Fatalf("unexpected backend configuration: %+v", config.Backend) + } + slot8080, err := config.Backend.SlotForPort(BackendPort8080) + if err != nil { + t.Fatalf("read 8080 slot: %v", err) + } + if slot8080.Unit != nativeUnit8080 || slot8080.JAR != nativeSlotJAR8080 || slot8080.HealthEndpoint != nativeHealthURL8080 { + t.Fatalf("unexpected 8080 slot: %+v", slot8080) + } + slot8081, err := config.Backend.SlotForPort(BackendPort8081) + if err != nil { + t.Fatalf("read 8081 slot: %v", err) + } + if slot8081.Unit != nativeUnit8081 || slot8081.JAR != nativeSlotJAR8081 || slot8081.HealthEndpoint != nativeHealthURL8081 { + t.Fatalf("unexpected 8081 slot: %+v", slot8081) + } +} + +func TestPackagedNativeConfigurationMatchesContract(t *testing.T) { + path, err := filepath.Abs(filepath.Join("..", "..", "packaging", "etc", "yms-daemon", "yms-daemon.toml")) + if err != nil { + t.Fatalf("resolve packaged native deployment configuration: %v", err) + } + if _, err := Load(path); err != nil { + t.Fatalf("load packaged native deployment configuration: %v", err) + } +} + +func TestLoadRejectsUnknownField(t *testing.T) { + content := strings.Replace(validNativeConfig, "systemctl_path = \"/bin/systemctl\"", "systemctl_path = \"/bin/systemctl\"\nSystemctlPath = \"/usr/bin/systemctl\"", 1) + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "SystemctlPath") { + t.Fatalf("expected exact unknown field rejection, got %v", err) + } +} + +func TestLoadRejectsWrongTableCase(t *testing.T) { + content := strings.Replace(validNativeConfig, "[backend]", "[Backend]", 1) + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "Backend") { + t.Fatalf("expected exact table case rejection, got %v", err) + } +} + +func TestLoadRejectsMissingNativeField(t *testing.T) { + content := strings.Replace(validNativeConfig, "active_jar = \"/home/yms/lib/glory-soft-yms.jar\"\n", "", 1) + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "backend.active_jar") { + t.Fatalf("expected missing active JAR rejection, got %v", err) + } +} + +func TestLoadRejectsUnknownSlot(t *testing.T) { + content := validNativeConfig + ` +[backend.slot.9090] +unit = "yms-backend@9090.service" +jar = "/home/yms/lib/glory-soft-yms-9090.jar" +health_endpoint = "http://127.0.0.1:9090/yms/actuator/health" +` + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "9090") { + t.Fatalf("expected unknown slot rejection, got %v", err) + } +} + +func TestLoadRejectsChangedSlotEndpoint(t *testing.T) { + content := strings.Replace(validNativeConfig, nativeHealthURL8080, nativeHealthURL8081, 1) + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "backend.slot.8080.health_endpoint") { + t.Fatalf("expected changed health endpoint rejection, got %v", err) + } +} + +func TestLoadAcceptsExplicitUsrBinSystemctlPath(t *testing.T) { + content := strings.Replace(validNativeConfig, "/bin/systemctl", "/usr/bin/systemctl", 1) + config, err := Load(writeConfig(t, content)) + if err != nil { + t.Fatalf("load explicit /usr/bin/systemctl path: %v", err) + } + if config.Backend.SystemctlPath != "/usr/bin/systemctl" { + t.Fatalf("unexpected systemctl path: %q", config.Backend.SystemctlPath) + } +} + +func TestLoadRejectsChangedBackendTypeCase(t *testing.T) { + content := strings.Replace(validNativeConfig, `type = "native"`, `type = "Native"`, 1) + _, err := Load(writeConfig(t, content)) + if err == nil || !strings.Contains(err.Error(), "backend.type") { + t.Fatalf("expected exact backend type rejection, got %v", err) + } +} + +func TestLoadRejectsDuplicateField(t *testing.T) { + content := strings.Replace(validNativeConfig, "type = \"native\"", "type = \"native\"\ntype = \"native\"", 1) + _, err := Load(writeConfig(t, content)) + if err == nil { + t.Fatal("expected duplicate field rejection") + } +} + +func TestLoadRequiresAbsoluteConfigurationPath(t *testing.T) { + _, err := Load("yms-daemon.toml") + if err == nil || !strings.Contains(err.Error(), "absolute path") { + t.Fatalf("expected absolute configuration path rejection, got %v", err) + } +} + +func TestSlotForPortRejectsUnsupportedPort(t *testing.T) { + config, err := Load(writeConfig(t, validNativeConfig)) + if err != nil { + t.Fatalf("load native deployment configuration: %v", err) + } + if _, err := config.Backend.SlotForPort(9090); err == nil { + t.Fatal("expected unsupported port rejection") + } +} + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "yms-daemon.toml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write deployment configuration: %v", err) + } + return path +} diff --git a/internal/filestore/store.go b/internal/filestore/store.go index c147d36..24153d5 100644 --- a/internal/filestore/store.go +++ b/internal/filestore/store.go @@ -20,6 +20,12 @@ type Identity struct { SHA256 string } +// Validate checks the exact immutable file identity without reading a file. +func (i Identity) Validate() error { + _, err := validateIdentity(i) + return err +} + // File 是一次原子提交的结果。 type File struct { Path string @@ -61,10 +67,10 @@ func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) if err != nil { return File{}, err } - if !filepath.IsLocal(relativePath) || relativePath == "." { - return File{}, fmt.Errorf("file store path is not a local relative path: %q", relativePath) + target, err := s.destination(relativePath) + if err != nil { + return File{}, err } - target := filepath.Join(s.root, filepath.Clean(relativePath)) parent := filepath.Dir(target) if err := os.MkdirAll(parent, 0o750); err != nil { return File{}, fmt.Errorf("create destination directory: %w", err) @@ -139,6 +145,36 @@ func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) return File{Path: target, Identity: expected}, nil } +// Inspect verifies an immutable destination without changing it. +// found=false means that the exact destination does not exist. +func (s *Store) Inspect(relativePath string, expected Identity) (file File, found bool, err error) { + expectedDigest, err := validateIdentity(expected) + if err != nil { + return File{}, false, err + } + target, err := s.destination(relativePath) + if err != nil { + return File{}, false, err + } + parent := filepath.Dir(target) + if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) { + return File{}, false, nil + } else if err != nil { + return File{}, false, fmt.Errorf("inspect destination directory: %w", err) + } + if err := s.verifyParent(parent); err != nil { + return File{}, false, err + } + return verifyExisting(target, expected, expectedDigest) +} + +func (s *Store) destination(relativePath string) (string, error) { + if !filepath.IsLocal(relativePath) || relativePath == "." { + return "", fmt.Errorf("file store path is not a local relative path: %q", relativePath) + } + return filepath.Join(s.root, filepath.Clean(relativePath)), nil +} + func (s *Store) verifyParent(parent string) error { resolvedParent, err := filepath.EvalSymlinks(parent) if err != nil { diff --git a/internal/filestore/store_test.go b/internal/filestore/store_test.go index 8dacbfb..d39307b 100644 --- a/internal/filestore/store_test.go +++ b/internal/filestore/store_test.go @@ -38,6 +38,35 @@ func TestStoreCommitsAndReusesImmutableFile(t *testing.T) { } } +func TestStoreInspectsCommittedIdentityWithoutMutation(t *testing.T) { + t.Parallel() + store, err := New(t.TempDir()) + if err != nil { + t.Fatalf("create file store: %v", err) + } + content := []byte("immutable backend jar") + identity := identityOf(content) + if _, err := store.Commit("releases/backend.jar", bytes.NewReader(content), identity); err != nil { + t.Fatalf("commit file: %v", err) + } + inspected, found, err := store.Inspect("releases/backend.jar", identity) + if err != nil || !found || inspected.Path == "" || !inspected.Reused { + t.Fatalf("inspect committed file: file=%+v found=%t err=%v", inspected, found, err) + } + _, found, err = store.Inspect("releases/missing.jar", identity) + if err != nil || found { + t.Fatalf("inspect missing file: found=%t err=%v", found, err) + } + _, found, err = store.Inspect("missing/directory/backend.jar", identity) + if err != nil || found { + t.Fatalf("inspect file below missing directory: found=%t err=%v", found, err) + } + _, _, err = store.Inspect("releases/backend.jar", identityOf([]byte("different"))) + if !errors.Is(err, ErrDestinationConflict) { + t.Fatalf("expected immutable identity conflict, got %v", err) + } +} + func TestStoreRejectsMismatchAndNeverPublishesInvalidFile(t *testing.T) { t.Parallel() root := t.TempDir() diff --git a/internal/hostnginx/config.go b/internal/hostnginx/config.go new file mode 100644 index 0000000..06aa2fc --- /dev/null +++ b/internal/hostnginx/config.go @@ -0,0 +1,155 @@ +// Package hostnginx manages the exact backend upstream block used by the current host Nginx deployment. +package hostnginx + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" +) + +const ( + managedBegin = "# yms-update managed upstream begin" + managedEnd = "# yms-update managed upstream end" + port8080 = 8080 + port8081 = 8081 +) + +// ActiveBackendPort reads the one uncommented backend server in the managed upstream block. +func ActiveBackendPort(content []byte) (int, error) { + block, err := parseManagedBlock(content) + if err != nil { + return 0, err + } + active := 0 + for _, server := range block.servers { + if !server.commented { + if active != 0 { + return 0, errors.New("managed Nginx upstream contains more than one active backend server") + } + active = server.port + } + } + if active == 0 { + return 0, errors.New("managed Nginx upstream does not contain an active backend server") + } + return active, nil +} + +// RenderBackendPort returns a complete configuration with only activePort uncommented. +func RenderBackendPort(content []byte, activePort int) ([]byte, error) { + if activePort != port8080 && activePort != port8081 { + return nil, fmt.Errorf("host Nginx backend port must be 8080 or 8081: %d", activePort) + } + block, err := parseManagedBlock(content) + if err != nil { + return nil, err + } + lines := block.lines + for _, server := range block.servers { + indentLength := len(lines[server.line]) - len(strings.TrimLeft(lines[server.line], " \t")) + indent := lines[server.line][:indentLength] + serverText := strings.TrimSpace(lines[server.line]) + serverText = strings.TrimPrefix(serverText, "# ") + if server.port == activePort { + lines[server.line] = indent + serverText + } else { + lines[server.line] = indent + "# " + serverText + } + } + rendered := []byte(strings.Join(lines, "\n")) + if _, err := ActiveBackendPort(rendered); err != nil { + return nil, fmt.Errorf("validate rendered host Nginx backend upstream: %w", err) + } + return rendered, nil +} + +type managedBlock struct { + lines []string + servers []managedServer +} + +type managedServer struct { + line int + port int + commented bool +} + +func parseManagedBlock(content []byte) (managedBlock, error) { + if len(content) == 0 { + return managedBlock{}, errors.New("host Nginx configuration is empty") + } + lines := strings.Split(string(content), "\n") + beginLine := -1 + endLine := -1 + for index, line := range lines { + switch strings.TrimSpace(line) { + case managedBegin: + if beginLine != -1 { + return managedBlock{}, errors.New("host Nginx configuration contains duplicate managed upstream begin markers") + } + beginLine = index + case managedEnd: + if endLine != -1 { + return managedBlock{}, errors.New("host Nginx configuration contains duplicate managed upstream end markers") + } + endLine = index + } + } + if beginLine == -1 || endLine == -1 || endLine <= beginLine { + return managedBlock{}, errors.New("host Nginx configuration requires one ordered managed upstream marker pair") + } + + servers := make([]managedServer, 0, 2) + seenPorts := make(map[int]struct{}, 2) + for index := beginLine + 1; index < endLine; index++ { + server, found, err := parseManagedServer(lines[index], index) + if err != nil { + return managedBlock{}, err + } + if !found { + if strings.TrimSpace(lines[index]) != "" { + return managedBlock{}, fmt.Errorf("managed Nginx upstream contains an unexpected line: %q", strings.TrimSpace(lines[index])) + } + continue + } + if _, duplicate := seenPorts[server.port]; duplicate { + return managedBlock{}, fmt.Errorf("managed Nginx upstream contains duplicate port %d", server.port) + } + seenPorts[server.port] = struct{}{} + servers = append(servers, server) + } + for _, port := range []int{port8080, port8081} { + if _, found := seenPorts[port]; !found { + return managedBlock{}, fmt.Errorf("managed Nginx upstream is missing port %d", port) + } + } + if len(servers) != 2 { + return managedBlock{}, fmt.Errorf("managed Nginx upstream must contain exactly two backend servers, got %d", len(servers)) + } + return managedBlock{lines: lines, servers: servers}, nil +} + +func parseManagedServer(line string, lineIndex int) (managedServer, bool, error) { + trimmed := strings.TrimSpace(line) + commented := strings.HasPrefix(trimmed, "# server ") + active := strings.HasPrefix(trimmed, "server ") + if !commented && !active { + return managedServer{}, false, nil + } + serverText := strings.TrimPrefix(trimmed, "# ") + fields := strings.Fields(serverText) + if len(fields) != 4 || fields[0] != "server" || fields[2] != "max_fails=1" || fields[3] != "fail_timeout=2s;" { + return managedServer{}, false, fmt.Errorf("managed Nginx upstream server line has an unsupported format: %q", trimmed) + } + _, portText, err := net.SplitHostPort(fields[1]) + if err != nil { + return managedServer{}, false, fmt.Errorf("parse managed Nginx upstream address %q: %w", fields[1], err) + } + port, err := strconv.Atoi(portText) + if err != nil || (port != port8080 && port != port8081) { + return managedServer{}, false, fmt.Errorf("managed Nginx upstream contains unsupported backend port %q", portText) + } + return managedServer{line: lineIndex, port: port, commented: commented}, true, nil +} diff --git a/internal/hostnginx/config_test.go b/internal/hostnginx/config_test.go new file mode 100644 index 0000000..cde7257 --- /dev/null +++ b/internal/hostnginx/config_test.go @@ -0,0 +1,55 @@ +package hostnginx + +import ( + "bytes" + "strings" + "testing" +) + +const serverConfiguration = `http { + upstream yms-server { + # yms-update managed upstream begin + # server 10.11.1.117:8080 max_fails=1 fail_timeout=2s; + server 10.11.1.117:8081 max_fails=1 fail_timeout=2s; + # yms-update managed upstream end + } +} +` + +func TestActiveBackendPortReadsExactManagedBlock(t *testing.T) { + port, err := ActiveBackendPort([]byte(serverConfiguration)) + if err != nil || port != 8081 { + t.Fatalf("unexpected active backend port: port=%d err=%v", port, err) + } +} + +func TestRenderBackendPortPreservesConfigurationAndSwitchesOneServer(t *testing.T) { + rendered, err := RenderBackendPort([]byte(serverConfiguration), 8080) + if err != nil { + t.Fatalf("render backend port: %v", err) + } + port, err := ActiveBackendPort(rendered) + if err != nil || port != 8080 { + t.Fatalf("unexpected rendered backend port: port=%d err=%v", port, err) + } + if !bytes.Contains(rendered, []byte("server 10.11.1.117:8080 max_fails=1 fail_timeout=2s;")) || + !bytes.Contains(rendered, []byte("# server 10.11.1.117:8081 max_fails=1 fail_timeout=2s;")) { + t.Fatalf("rendered configuration does not contain exact server lines:\n%s", rendered) + } +} + +func TestManagedBlockRejectsAmbiguousOrAlteredInput(t *testing.T) { + tests := map[string]string{ + "both active": strings.Replace(serverConfiguration, "# server 10.11.1.117:8080", "server 10.11.1.117:8080", 1), + "missing port": strings.Replace(serverConfiguration, " # server 10.11.1.117:8080 max_fails=1 fail_timeout=2s;\n", "", 1), + "changed option": strings.Replace(serverConfiguration, "max_fails=1", "max_fails=2", 1), + "extra line": strings.Replace(serverConfiguration, managedEnd, "# unexpected\n "+managedEnd, 1), + } + for name, content := range tests { + t.Run(name, func(t *testing.T) { + if _, err := ActiveBackendPort([]byte(content)); err == nil { + t.Fatal("expected altered managed block rejection") + } + }) + } +} diff --git a/internal/hostnginx/controller.go b/internal/hostnginx/controller.go new file mode 100644 index 0000000..f0c7c64 --- /dev/null +++ b/internal/hostnginx/controller.go @@ -0,0 +1,245 @@ +package hostnginx + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Snapshot is the complete host Nginx configuration before or after a switch. +type Snapshot struct { + Content []byte + ActivePort int +} + +// Controller validates, atomically replaces, and reloads the current host Nginx configuration. +type Controller struct { + configPath string + nginxExecutable string + systemctlPath string + nginxServiceName string + runner commandRunner +} + +// NewController requires every external identifier to be supplied explicitly. +func NewController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string) (*Controller, error) { + return newController(configPath, nginxExecutable, systemctlPath, nginxServiceName, execRunner{}) +} + +func newController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string, runner commandRunner) (*Controller, error) { + for _, entry := range []struct { + name string + value string + }{ + {"host Nginx configuration", configPath}, + {"Nginx executable", nginxExecutable}, + {"systemctl executable", systemctlPath}, + } { + if !filepath.IsAbs(entry.value) || filepath.Clean(entry.value) != entry.value { + return nil, fmt.Errorf("%s must be a clean absolute path", entry.name) + } + } + if nginxServiceName == "" || strings.TrimSpace(nginxServiceName) != nginxServiceName { + return nil, errors.New("exact Nginx systemd service name is required") + } + if runner == nil { + return nil, errors.New("host Nginx command runner is required") + } + return &Controller{ + configPath: configPath, + nginxExecutable: nginxExecutable, + systemctlPath: systemctlPath, + nginxServiceName: nginxServiceName, + runner: runner, + }, nil +} + +// Read returns the complete current configuration and its active backend port. +func (c *Controller) Read() (Snapshot, error) { + info, err := os.Lstat(c.configPath) + if err != nil { + return Snapshot{}, fmt.Errorf("inspect host Nginx configuration %s: %w", c.configPath, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return Snapshot{}, fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath) + } + content, err := os.ReadFile(c.configPath) + if err != nil { + return Snapshot{}, fmt.Errorf("read host Nginx configuration %s: %w", c.configPath, err) + } + port, err := ActiveBackendPort(content) + if err != nil { + return Snapshot{}, err + } + return Snapshot{Content: content, ActivePort: port}, nil +} + +// Switch renders activePort from the current configuration, then validates and reloads Nginx. +// It returns the durable pre-switch snapshot required for compensation. +func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, error) { + previous, err := c.Read() + if err != nil { + return Snapshot{}, err + } + if previous.ActivePort == activePort { + return previous, nil + } + next, err := RenderBackendPort(previous.Content, activePort) + if err != nil { + return Snapshot{}, err + } + if err := c.replaceValidateReload(ctx, next, previous.Content); err != nil { + return Snapshot{}, err + } + return previous, nil +} + +// Restore atomically restores a previously persisted complete configuration and reloads Nginx. +func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error { + if len(snapshot.Content) == 0 { + return errors.New("host Nginx restore snapshot is empty") + } + parsedPort, err := ActiveBackendPort(snapshot.Content) + if err != nil { + return fmt.Errorf("validate host Nginx restore snapshot: %w", err) + } + if parsedPort != snapshot.ActivePort { + return fmt.Errorf("host Nginx restore snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort) + } + current, err := c.Read() + if err != nil { + return err + } + if string(current.Content) == string(snapshot.Content) { + return nil + } + return c.replaceValidateReload(ctx, snapshot.Content, current.Content) +} + +// Apply installs one previously persisted complete snapshot and reloads Nginx. +func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error { + if len(snapshot.Content) == 0 { + return errors.New("host Nginx apply snapshot is empty") + } + parsedPort, err := ActiveBackendPort(snapshot.Content) + if err != nil { + return fmt.Errorf("validate host Nginx apply snapshot: %w", err) + } + if parsedPort != snapshot.ActivePort { + return fmt.Errorf("host Nginx apply snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort) + } + current, err := c.Read() + if err != nil { + return err + } + return c.replaceValidateReload(ctx, snapshot.Content, current.Content) +} + +func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte, rollback []byte) error { + if err := c.atomicWrite(desired); err != nil { + return err + } + if err := c.runner.Run(ctx, c.nginxExecutable, "-t"); err != nil { + return errors.Join( + fmt.Errorf("validate host Nginx configuration: %w", err), + c.restoreAfterFailure(ctx, rollback), + ) + } + if err := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName); err != nil { + return errors.Join( + fmt.Errorf("reload host Nginx service %s: %w", c.nginxServiceName, err), + c.restoreAfterFailure(ctx, rollback), + ) + } + return nil +} + +func (c *Controller) restoreAfterFailure(ctx context.Context, content []byte) error { + if err := c.atomicWrite(content); err != nil { + return fmt.Errorf("restore host Nginx configuration after failure: %w", err) + } + validateErr := c.runner.Run(ctx, c.nginxExecutable, "-t") + reloadErr := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName) + return errors.Join( + wrapError("validate restored host Nginx configuration", validateErr), + wrapError("reload restored host Nginx configuration", reloadErr), + ) +} + +func (c *Controller) atomicWrite(content []byte) error { + info, err := os.Lstat(c.configPath) + if err != nil { + return fmt.Errorf("inspect host Nginx configuration before replacement: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath) + } + parent := filepath.Dir(c.configPath) + temporary, err := os.CreateTemp(parent, ".yms-daemon-nginx-*") + if err != nil { + return fmt.Errorf("create temporary host Nginx configuration: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(info.Mode().Perm()); err != nil { + _ = temporary.Close() + return fmt.Errorf("set temporary host Nginx configuration permissions: %w", err) + } + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() + return fmt.Errorf("write temporary host Nginx configuration: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("flush temporary host Nginx configuration: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary host Nginx configuration: %w", err) + } + if err := os.Rename(temporaryPath, c.configPath); err != nil { + return fmt.Errorf("replace host Nginx configuration: %w", err) + } + return syncDirectory(parent) +} + +type commandRunner interface { + Run(context.Context, string, ...string) error +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, executable string, arguments ...string) error { + output, err := exec.CommandContext(ctx, executable, arguments...).CombinedOutput() + if err == nil { + return nil + } + detail := strings.TrimSpace(string(output)) + if detail == "" { + return err + } + return fmt.Errorf("%w: %s", err, detail) +} + +func wrapError(message string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", message, err) +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return fmt.Errorf("open host Nginx configuration directory: %w", err) + } + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("flush host Nginx configuration directory: %w", err) + } + return nil +} diff --git a/internal/hostnginx/controller_test.go b/internal/hostnginx/controller_test.go new file mode 100644 index 0000000..8a39382 --- /dev/null +++ b/internal/hostnginx/controller_test.go @@ -0,0 +1,103 @@ +package hostnginx + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestControllerSwitchesAndReloadsExactService(t *testing.T) { + configPath := writeNginxConfig(t, serverConfiguration) + runner := &recordingRunner{} + controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + if err != nil { + t.Fatalf("create host Nginx controller: %v", err) + } + + previous, err := controller.Switch(context.Background(), 8080) + if err != nil { + t.Fatalf("switch host Nginx backend: %v", err) + } + if previous.ActivePort != 8081 { + t.Fatalf("unexpected previous port: %d", previous.ActivePort) + } + current, err := controller.Read() + if err != nil || current.ActivePort != 8080 { + t.Fatalf("unexpected current host Nginx configuration: snapshot=%+v err=%v", current, err) + } + wantCalls := [][]string{ + {"/usr/sbin/nginx", "-t"}, + {"/bin/systemctl", "reload", "--", "nginx.service"}, + } + if !slices.EqualFunc(runner.calls, wantCalls, slices.Equal) { + t.Fatalf("unexpected host Nginx commands: %+v", runner.calls) + } +} + +func TestControllerRestoresConfigurationWhenValidationFails(t *testing.T) { + configPath := writeNginxConfig(t, serverConfiguration) + runner := &recordingRunner{errors: []error{errors.New("nginx test failed"), nil, nil}} + controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + if err != nil { + t.Fatalf("create host Nginx controller: %v", err) + } + + if _, err := controller.Switch(context.Background(), 8080); err == nil { + t.Fatal("expected host Nginx validation failure") + } + current, err := controller.Read() + if err != nil || current.ActivePort != 8081 || string(current.Content) != serverConfiguration { + t.Fatalf("host Nginx configuration was not restored: snapshot=%+v err=%v", current, err) + } + if len(runner.calls) != 3 { + t.Fatalf("unexpected validation compensation calls: %+v", runner.calls) + } +} + +func TestControllerRestoreUsesCompleteSnapshot(t *testing.T) { + configPath := writeNginxConfig(t, serverConfiguration) + runner := &recordingRunner{} + controller, err := newController(configPath, "/usr/sbin/nginx", "/bin/systemctl", "nginx.service", runner) + if err != nil { + t.Fatalf("create host Nginx controller: %v", err) + } + previous, err := controller.Switch(context.Background(), 8080) + if err != nil { + t.Fatalf("switch host Nginx backend: %v", err) + } + if err := controller.Restore(context.Background(), previous); err != nil { + t.Fatalf("restore host Nginx backend: %v", err) + } + current, err := controller.Read() + if err != nil || current.ActivePort != 8081 || string(current.Content) != serverConfiguration { + t.Fatalf("unexpected restored configuration: snapshot=%+v err=%v", current, err) + } +} + +func writeNginxConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "nginx.conf") + if err := os.WriteFile(path, []byte(content), 0o640); err != nil { + t.Fatalf("write host Nginx configuration: %v", err) + } + return path +} + +type recordingRunner struct { + calls [][]string + errors []error +} + +func (r *recordingRunner) Run(_ context.Context, executable string, arguments ...string) error { + call := append([]string{executable}, arguments...) + r.calls = append(r.calls, call) + if len(r.errors) == 0 { + return nil + } + err := r.errors[0] + r.errors = r.errors[1:] + return err +} diff --git a/internal/nativebackendexecutor/executor.go b/internal/nativebackendexecutor/executor.go new file mode 100644 index 0000000..8ce57f0 --- /dev/null +++ b/internal/nativebackendexecutor/executor.go @@ -0,0 +1,404 @@ +// Package nativebackendexecutor prepares and starts one explicitly configured native backend slot. +// Gateway switching is deliberately outside this package. +package nativebackendexecutor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "yms-daemon/internal/filestore" + "yms-daemon/internal/healthcheck" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +const ( + healthPath = "/yms/actuator/health" + healthTimeout = 120 * time.Second + healthInterval = time.Second + stepInstallJar = "backend.native.jar.install" + stepBindSlot = "backend.native.slot.bind" + stepStartUnit = "backend.native.service.start" + stepCheckHealth = "backend.native.health" + stepStopUnit = "backend.native.service.stop" + stepRestoreSlot = "backend.native.slot.restore" + activeState = "active" + inactiveState = "inactive" + failedState = "failed" +) + +// Request contains exact values from the immutable update request and local deployment configuration. +// UnitName, SlotJarPath and PreviousSlotTarget are opaque and are never derived from filenames or ports. +type Request struct { + ArtifactPath string + ArtifactIdentity filestore.Identity + ReleasePath string + SlotJarPath string + PreviousSlotTarget string + UnitName string + Port int + HealthEndpoint string + Progress func(transaction.State, string) +} + +type actuatorChecker interface { + Check(context.Context, string, healthcheck.RunningProbe) (healthcheck.ActuatorReport, bool, error) + Wait(context.Context, string, time.Duration, healthcheck.RunningProbe) (healthcheck.ActuatorReport, error) +} + +// Executor drives the persisted transaction up to SWITCHING after the inactive native backend is healthy. +type Executor struct { + store *transaction.Store + coordinator *transaction.Coordinator + releaseStore *filestore.Store + units systemd.Manager + checker actuatorChecker +} + +func New(store *transaction.Store, coordinator *transaction.Coordinator, releaseStore *filestore.Store, units systemd.Manager, httpClient *http.Client) (*Executor, error) { + if store == nil { + return nil, errors.New("transaction store is required") + } + if coordinator == nil { + return nil, errors.New("transaction coordinator is required") + } + if releaseStore == nil { + return nil, errors.New("native backend release store is required") + } + if units == nil { + return nil, errors.New("systemd manager is required") + } + checker, err := healthcheck.NewActuatorChecker(httpClient, healthInterval) + if err != nil { + return nil, err + } + return &Executor{store: store, coordinator: coordinator, releaseStore: releaseStore, units: units, checker: checker}, nil +} + +// Run resumes from the transaction's persisted state. It does not switch gateway traffic. +func (e *Executor) Run(ctx context.Context, transactionID string, request Request) error { + if strings.TrimSpace(transactionID) == "" { + return errors.New("transaction ID is required") + } + return e.coordinator.RunExclusive(ctx, func(ctx context.Context) error { + return e.run(ctx, transactionID, request) + }) +} + +func (e *Executor) run(ctx context.Context, transactionID string, request Request) error { + for { + record, err := e.store.Transaction(ctx, transactionID) + if err != nil { + return err + } + switch record.State { + case transaction.StateCreated: + if _, err := e.store.Transition(ctx, transactionID, transaction.StateValidating, "native backend validation started"); err != nil { + return err + } + case transaction.StateValidating: + reportProgress(request, record.State, "Validating inactive native backend slot") + if err := e.validate(ctx, request); err != nil { + _, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error()) + return errors.Join(err, transitionErr) + } + if _, err := e.store.Transition(ctx, transactionID, transaction.StatePrepared, "native backend inputs validated"); err != nil { + return err + } + case transaction.StatePrepared: + reportProgress(request, record.State, "Installing backend JAR and binding the inactive slot") + if _, err := e.prepare(ctx, transactionID, request); err != nil { + return e.failUnlessRecoverable(ctx, transactionID, err) + } + if _, err := e.store.Transition(ctx, transactionID, transaction.StateStarting, "native backend slot prepared"); err != nil { + return err + } + case transaction.StateStarting: + reportProgress(request, record.State, fmt.Sprintf("Preparing to start %s on port %d", request.UnitName, request.Port)) + installedPath, err := e.prepare(ctx, transactionID, request) + if err != nil { + return e.failUnlessRecoverable(ctx, transactionID, err) + } + if err := e.startAndCheck(ctx, transactionID, request); err != nil { + if recoverable(err) { + return err + } + return e.rollbackBeforeSwitch(ctx, transactionID, request, installedPath, err) + } + if _, err := e.store.Transition(ctx, transactionID, transaction.StateSwitching, "native backend is healthy"); err != nil { + return err + } + case transaction.StateSwitching: + return nil + case transaction.StateRollingBack: + reportProgress(request, record.State, "Resuming native backend preparation compensation") + installed, found, err := e.releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity) + if err != nil { + return fmt.Errorf("inspect native backend JAR while resuming compensation: %w", err) + } + if !found { + return errors.New("installed native backend JAR is missing while compensation is pending") + } + if err := e.compensateBeforeSwitch(ctx, transactionID, request, installed.Path); err != nil { + return err + } + if _, err := e.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend preparation rollback resumed and completed"); err != nil { + return err + } + return nil + default: + return fmt.Errorf("native backend executor cannot run transaction %s in state %s", transactionID, record.State) + } + } +} + +func (e *Executor) validate(ctx context.Context, request Request) error { + if err := validateRequest(request); err != nil { + return err + } + info, err := os.Lstat(request.ArtifactPath) + if err != nil { + return fmt.Errorf("inspect native backend JAR %s: %w", request.ArtifactPath, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("native backend JAR is not a regular file: %s", request.ArtifactPath) + } + if info.Size() != request.ArtifactIdentity.Size { + return fmt.Errorf("native backend JAR size mismatch: got %d, want %d", info.Size(), request.ArtifactIdentity.Size) + } + if err := inspectInitialSlot(request.SlotJarPath, request.PreviousSlotTarget); err != nil { + return err + } + unit, err := e.units.Inspect(ctx, request.UnitName) + if err != nil { + return fmt.Errorf("inspect inactive native backend unit: %w", err) + } + if unit.ActiveState != inactiveState && unit.ActiveState != failedState { + return fmt.Errorf("native backend unit %s must be inactive or failed before preparation, active state is %q", request.UnitName, unit.ActiveState) + } + return nil +} + +func (e *Executor) prepare(ctx context.Context, transactionID string, request Request) (string, error) { + installOperation := &installJarOperation{ + store: e.releaseStore, + sourcePath: request.ArtifactPath, + releasePath: request.ReleasePath, + identity: request.ArtifactIdentity, + } + if _, err := e.coordinator.ExecuteStep(ctx, transactionID, installIntent(request), installOperation); err != nil { + return "", err + } + installed, found, err := e.releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity) + if err != nil { + return "", fmt.Errorf("inspect installed native backend JAR: %w", err) + } + if !found { + return "", errors.New("installed native backend JAR is missing after completed install step") + } + + bindOperation := &slotLinkOperation{ + path: request.SlotJarPath, + desiredTarget: installed.Path, + previousTarget: request.PreviousSlotTarget, + } + if _, err := e.coordinator.ExecuteStep(ctx, transactionID, bindIntent(request, installed.Path), bindOperation); err != nil { + return "", err + } + return installed.Path, nil +} + +func (e *Executor) startAndCheck(ctx context.Context, transactionID string, request Request) error { + reportProgress(request, transaction.StateStarting, "Starting native backend unit "+request.UnitName) + startOperation := &unitStartOperation{units: e.units, name: request.UnitName} + if _, err := e.coordinator.ExecuteStep(ctx, transactionID, startIntent(request), startOperation); err != nil { + return err + } + reportProgress(request, transaction.StateStarting, fmt.Sprintf("Waiting up to %s for Actuator health: %s", healthTimeout, request.HealthEndpoint)) + healthOperation := &healthOperation{ + units: e.units, + checker: e.checker, + unitName: request.UnitName, + endpoint: request.HealthEndpoint, + timeout: healthTimeout, + } + _, err := e.coordinator.ExecuteStep(ctx, transactionID, healthIntent(request), healthOperation) + if err == nil { + reportProgress(request, transaction.StateStarting, "Actuator health status is UP") + } + return err +} + +func reportProgress(request Request, state transaction.State, message string) { + if request.Progress != nil { + request.Progress(state, message) + } +} + +func (e *Executor) rollbackBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string, cause error) error { + if _, err := e.store.Transition(ctx, transactionID, transaction.StateRollingBack, "native backend preparation failed; compensation started"); err != nil { + return errors.Join(cause, err) + } + if err := e.compensateBeforeSwitch(ctx, transactionID, request, installedPath); err != nil { + return errors.Join(cause, err) + } + if _, err := e.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend preparation rolled back"); err != nil { + return errors.Join(cause, err) + } + return cause +} + +func (e *Executor) compensateBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string) error { + stopOperation := &unitStopOperation{units: e.units, name: request.UnitName} + if _, err := e.coordinator.ExecuteStep(ctx, transactionID, stopIntent(request), stopOperation); err != nil { + return err + } + restoreOperation := &slotLinkOperation{ + path: request.SlotJarPath, + desiredTarget: request.PreviousSlotTarget, + previousTarget: installedPath, + } + if _, err := e.coordinator.ExecuteStep(ctx, transactionID, restoreIntent(request, installedPath), restoreOperation); err != nil { + return err + } + return nil +} + +func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID string, cause error) error { + if recoverable(cause) { + return cause + } + _, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, cause.Error()) + return errors.Join(cause, transitionErr) +} + +func recoverable(cause error) bool { + var uncertain *transaction.UncertainStepError + return errors.As(cause, &uncertain) || errors.Is(cause, transaction.ErrStepConflict) +} + +func validateRequest(request Request) error { + if !filepath.IsAbs(request.ArtifactPath) { + return errors.New("native backend JAR path must be absolute") + } + if err := request.ArtifactIdentity.Validate(); err != nil { + return fmt.Errorf("invalid native backend JAR identity: %w", err) + } + if !filepath.IsLocal(request.ReleasePath) || request.ReleasePath == "." { + return fmt.Errorf("native backend release path must be a local relative path: %q", request.ReleasePath) + } + if !filepath.IsAbs(request.SlotJarPath) { + return errors.New("native backend slot JAR path must be absolute") + } + if request.PreviousSlotTarget != "" && !filepath.IsAbs(request.PreviousSlotTarget) { + return errors.New("previous native backend slot target must be empty or absolute") + } + if request.UnitName == "" || strings.TrimSpace(request.UnitName) != request.UnitName { + return errors.New("exact native backend systemd unit name is required") + } + if request.Port != 8080 && request.Port != 8081 { + return fmt.Errorf("native backend port must be 8080 or 8081: %d", request.Port) + } + parsed, err := url.ParseRequestURI(request.HealthEndpoint) + if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != healthPath { + return fmt.Errorf("health endpoint must be an HTTP URL with exact path %s", healthPath) + } + if parsed.Port() != strconv.Itoa(request.Port) { + return fmt.Errorf("health endpoint port must equal native backend port %d", request.Port) + } + return nil +} + +func inspectInitialSlot(path, previousTarget string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if previousTarget == "" { + return nil + } + return fmt.Errorf("native backend slot link %s is missing; expected target %s", path, previousTarget) + } + if err != nil { + return fmt.Errorf("inspect native backend slot link %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("native backend slot path is not a symbolic link: %s", path) + } + target, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("read native backend slot link %s: %w", path, err) + } + if target != previousTarget { + return fmt.Errorf("native backend slot link %s targets %q, expected %q", path, target, previousTarget) + } + if previousTarget != "" { + targetInfo, err := os.Lstat(previousTarget) + if err != nil { + return fmt.Errorf("inspect previous native backend slot target %s: %w", previousTarget, err) + } + if !targetInfo.Mode().IsRegular() || targetInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("previous native backend slot target is not a regular file: %s", previousTarget) + } + } + return nil +} + +func installIntent(request Request) transaction.StepIntent { + return intent(stepInstallJar, "install immutable native backend JAR", struct { + SourcePath string `json:"sourcePath"` + ReleasePath string `json:"releasePath"` + Identity filestore.Identity `json:"identity"` + }{request.ArtifactPath, request.ReleasePath, request.ArtifactIdentity}) +} + +func bindIntent(request Request, installedPath string) transaction.StepIntent { + return intent(stepBindSlot, "bind inactive native backend slot", struct { + SlotJarPath string `json:"slotJarPath"` + PreviousTarget string `json:"previousTarget"` + InstalledPath string `json:"installedPath"` + }{request.SlotJarPath, request.PreviousSlotTarget, installedPath}) +} + +func startIntent(request Request) transaction.StepIntent { + return intent(stepStartUnit, "start inactive native backend systemd unit", struct { + UnitName string `json:"unitName"` + }{request.UnitName}) +} + +func healthIntent(request Request) transaction.StepIntent { + return intent(stepCheckHealth, "wait for native backend Actuator health", struct { + UnitName string `json:"unitName"` + Endpoint string `json:"endpoint"` + Timeout time.Duration `json:"timeout"` + }{request.UnitName, request.HealthEndpoint, healthTimeout}) +} + +func stopIntent(request Request) transaction.StepIntent { + return intent(stepStopUnit, "stop failed inactive native backend systemd unit", struct { + UnitName string `json:"unitName"` + }{request.UnitName}) +} + +func restoreIntent(request Request, installedPath string) transaction.StepIntent { + return intent(stepRestoreSlot, "restore inactive native backend slot", struct { + SlotJarPath string `json:"slotJarPath"` + InstalledPath string `json:"installedPath"` + PreviousTarget string `json:"previousTarget"` + }{request.SlotJarPath, installedPath, request.PreviousSlotTarget}) +} + +func intent(key, name string, value any) transaction.StepIntent { + payload, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("marshal internal step intent: %v", err)) + } + return transaction.StepIntent{Key: key, Name: name, Intent: payload} +} diff --git a/internal/nativebackendexecutor/executor_test.go b/internal/nativebackendexecutor/executor_test.go new file mode 100644 index 0000000..c0801fa --- /dev/null +++ b/internal/nativebackendexecutor/executor_test.go @@ -0,0 +1,430 @@ +package nativebackendexecutor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "yms-daemon/internal/filestore" + "yms-daemon/internal/healthcheck" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +func TestExecutorInstallsJarStartsExactUnitAndReachesSwitching(t *testing.T) { + ctx := context.Background() + executor, store, releaseStore, units, request, previousTarget := testNativeExecutor(t) + record := createNativeTransaction(t, store, "success") + + if err := executor.Run(ctx, record.ID, request); err != nil { + t.Fatalf("run native backend executor: %v", err) + } + current, err := store.Transaction(ctx, record.ID) + if err != nil || current.State != transaction.StateSwitching { + t.Fatalf("unexpected transaction after native preparation: record=%+v err=%v", current, err) + } + installed, found, err := releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity) + if err != nil || !found { + t.Fatalf("inspect installed backend JAR: file=%+v found=%t err=%v", installed, found, err) + } + actualTarget, err := os.Readlink(request.SlotJarPath) + if err != nil || actualTarget != installed.Path || actualTarget == previousTarget { + t.Fatalf("unexpected slot link: target=%q installed=%q previous=%q err=%v", actualTarget, installed.Path, previousTarget, err) + } + units.mu.Lock() + startCalls := units.startCalls + stopCalls := units.stopCalls + unitActiveState := units.unit.ActiveState + startedName := units.startedName + units.mu.Unlock() + if startCalls != 1 || stopCalls != 0 || unitActiveState != activeState || startedName != request.UnitName { + t.Fatalf("unexpected systemd calls: start=%d stop=%d state=%s name=%s", startCalls, stopCalls, unitActiveState, startedName) + } + pending, err := store.PendingSteps(ctx, record.ID) + if err != nil || len(pending) != 0 { + t.Fatalf("unexpected pending native steps: steps=%+v err=%v", pending, err) + } + + if err := executor.Run(ctx, record.ID, request); err != nil { + t.Fatalf("repeat executor at switching state: %v", err) + } + units.mu.Lock() + repeatedStartCalls := units.startCalls + units.mu.Unlock() + if repeatedStartCalls != startCalls { + t.Fatalf("switching state repeated systemd start: before=%d after=%d", startCalls, repeatedStartCalls) + } +} + +func TestExecutorRollsBackSlotAndStopsUnitWhenHealthFails(t *testing.T) { + ctx := context.Background() + executor, store, _, units, request, previousTarget := testNativeExecutor(t) + executor.checker = &fakeActuatorChecker{ + waitErr: errors.New("Actuator rejected native backend"), + checkReady: false, + } + record := createNativeTransaction(t, store, "health-failure") + + err := executor.Run(ctx, record.ID, request) + if err == nil { + t.Fatal("expected native backend health failure") + } + current, readErr := store.Transaction(ctx, record.ID) + if readErr != nil || current.State != transaction.StateRolledBack { + t.Fatalf("unexpected compensated transaction: record=%+v err=%v", current, readErr) + } + actualTarget, linkErr := os.Readlink(request.SlotJarPath) + if linkErr != nil || actualTarget != previousTarget { + t.Fatalf("native slot was not restored: target=%q previous=%q err=%v", actualTarget, previousTarget, linkErr) + } + units.mu.Lock() + startCalls := units.startCalls + stopCalls := units.stopCalls + unitActiveState := units.unit.ActiveState + units.mu.Unlock() + if startCalls != 1 || stopCalls != 1 || unitActiveState != inactiveState { + t.Fatalf("unexpected compensated systemd state: start=%d stop=%d state=%s", startCalls, stopCalls, unitActiveState) + } +} + +func TestExecutorResumesPersistedRollback(t *testing.T) { + ctx := context.Background() + executor, store, _, units, request, previousTarget := testNativeExecutor(t) + record := createNativeTransaction(t, store, "resume-rollback") + transitionNativeToPrepared(t, store, record.ID) + if _, err := executor.prepare(ctx, record.ID, request); err != nil { + t.Fatalf("prepare native backend before rollback interruption: %v", err) + } + if _, err := store.Transition(ctx, record.ID, transaction.StateStarting, "test starting"); err != nil { + t.Fatalf("transition native transaction to starting: %v", err) + } + if err := units.Start(ctx, request.UnitName); err != nil { + t.Fatalf("start native backend before rollback interruption: %v", err) + } + if _, err := store.Transition(ctx, record.ID, transaction.StateRollingBack, "test interrupted rollback"); err != nil { + t.Fatalf("persist interrupted rollback state: %v", err) + } + + if err := executor.Run(ctx, record.ID, request); err != nil { + t.Fatalf("resume native backend rollback: %v", err) + } + current, err := store.Transaction(ctx, record.ID) + if err != nil || current.State != transaction.StateRolledBack { + t.Fatalf("unexpected resumed rollback state: record=%+v err=%v", current, err) + } + actualTarget, err := os.Readlink(request.SlotJarPath) + if err != nil || actualTarget != previousTarget { + t.Fatalf("resumed rollback did not restore slot: target=%q previous=%q err=%v", actualTarget, previousTarget, err) + } + units.mu.Lock() + stopCalls := units.stopCalls + unitActiveState := units.unit.ActiveState + units.mu.Unlock() + if stopCalls != 1 || unitActiveState != inactiveState { + t.Fatalf("resumed rollback did not stop unit: stop=%d state=%s", stopCalls, unitActiveState) + } +} + +func TestExecutorRollbackRemovesFirstDeploymentSlotLink(t *testing.T) { + ctx := context.Background() + executor, store, _, _, request, _ := testNativeExecutor(t) + if err := os.Remove(request.SlotJarPath); err != nil { + t.Fatalf("remove seeded slot link: %v", err) + } + request.PreviousSlotTarget = "" + executor.checker = &fakeActuatorChecker{ + waitErr: errors.New("Actuator rejected first native backend deployment"), + checkReady: false, + } + record := createNativeTransaction(t, store, "first-deployment-rollback") + + if err := executor.Run(ctx, record.ID, request); err == nil { + t.Fatal("expected first native backend deployment health failure") + } + current, err := store.Transaction(ctx, record.ID) + if err != nil || current.State != transaction.StateRolledBack { + t.Fatalf("unexpected first deployment rollback state: record=%+v err=%v", current, err) + } + if _, err := os.Lstat(request.SlotJarPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("first deployment rollback retained slot link: %v", err) + } +} + +func TestExecutorRecoversRecordedSlotIntentWithoutChangingRequest(t *testing.T) { + ctx := context.Background() + executor, store, releaseStore, units, request, _ := testNativeExecutor(t) + record := createNativeTransaction(t, store, "recover-slot") + transitionNativeToPrepared(t, store, record.ID) + + installOperation := &installJarOperation{ + store: releaseStore, + sourcePath: request.ArtifactPath, + releasePath: request.ReleasePath, + identity: request.ArtifactIdentity, + } + if _, err := executor.coordinator.ExecuteStep(ctx, record.ID, installIntent(request), installOperation); err != nil { + t.Fatalf("install backend JAR before simulated crash: %v", err) + } + installed, found, err := releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity) + if err != nil || !found { + t.Fatalf("inspect backend JAR before simulated crash: file=%+v found=%t err=%v", installed, found, err) + } + if _, _, err := store.RecordStepIntent(ctx, record.ID, bindIntent(request, installed.Path)); err != nil { + t.Fatalf("record slot intent before simulated crash: %v", err) + } + + if err := executor.Run(ctx, record.ID, request); err != nil { + t.Fatalf("recover native backend executor: %v", err) + } + actualTarget, err := os.Readlink(request.SlotJarPath) + if err != nil || actualTarget != installed.Path { + t.Fatalf("unexpected recovered slot link: target=%q installed=%q err=%v", actualTarget, installed.Path, err) + } + units.mu.Lock() + startCalls := units.startCalls + units.mu.Unlock() + if startCalls != 1 { + t.Fatalf("unexpected recovered systemd start count: %d", startCalls) + } +} + +func TestExecutorRejectsChangedRecoveryIntentAndPreservesStartingState(t *testing.T) { + ctx := context.Background() + executor, store, _, _, request, _ := testNativeExecutor(t) + record := createNativeTransaction(t, store, "changed-request") + transitionNativeToPrepared(t, store, record.ID) + if _, err := executor.prepare(ctx, record.ID, request); err != nil { + t.Fatalf("prepare original native backend request: %v", err) + } + if _, err := store.Transition(ctx, record.ID, transaction.StateStarting, "test starting"); err != nil { + t.Fatalf("transition native backend to starting: %v", err) + } + + changed := request + changed.ReleasePath = "different-release.jar" + err := executor.Run(ctx, record.ID, changed) + if !errors.Is(err, transaction.ErrStepConflict) { + t.Fatalf("expected persisted native intent conflict, got %v", err) + } + current, readErr := store.Transaction(ctx, record.ID) + if readErr != nil || current.State != transaction.StateStarting { + t.Fatalf("changed recovery request altered transaction: record=%+v err=%v", current, readErr) + } +} + +func TestExecutorRejectsActiveUnitBeforeChangingFiles(t *testing.T) { + ctx := context.Background() + executor, store, releaseStore, units, request, previousTarget := testNativeExecutor(t) + units.mu.Lock() + units.unit.ActiveState = activeState + units.unit.SubState = "running" + units.mu.Unlock() + record := createNativeTransaction(t, store, "active-unit") + + if err := executor.Run(ctx, record.ID, request); err == nil { + t.Fatal("expected active unit validation failure") + } + current, err := store.Transaction(ctx, record.ID) + if err != nil || current.State != transaction.StateFailed { + t.Fatalf("unexpected active-unit transaction state: record=%+v err=%v", current, err) + } + if _, found, err := releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity); err != nil || found { + t.Fatalf("validation failure changed release store: found=%t err=%v", found, err) + } + actualTarget, err := os.Readlink(request.SlotJarPath) + if err != nil || actualTarget != previousTarget { + t.Fatalf("validation failure changed slot link: target=%q previous=%q err=%v", actualTarget, previousTarget, err) + } +} + +func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) { + units := &fakeUnitManager{unit: systemd.Unit{ + Name: "yms-backend@8080.service", + LoadState: "loaded", + ActiveState: failedState, + SubState: "failed", + }} + operation := &unitStopOperation{units: units, name: units.unit.Name} + inspection, err := operation.Inspect(context.Background()) + if err != nil { + t.Fatalf("inspect stopped native backend unit: %v", err) + } + if inspection.Status != transaction.InspectionApplied { + t.Fatalf("unexpected stopped native backend unit inspection: %+v", inspection) + } +} + +func testNativeExecutor(t *testing.T) (*Executor, *transaction.Store, *filestore.Store, *fakeUnitManager, Request, string) { + t.Helper() + store, err := transaction.OpenStore(context.Background(), filepath.Join(t.TempDir(), "transactions.db")) + if err != nil { + t.Fatalf("open transaction store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + coordinator, err := transaction.NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create transaction coordinator: %v", err) + } + releaseStore, err := filestore.New(t.TempDir()) + if err != nil { + t.Fatalf("create native release store: %v", err) + } + artifactContent := []byte("native backend jar content") + artifactPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + if err := os.WriteFile(artifactPath, artifactContent, 0o640); err != nil { + t.Fatalf("write native backend artifact: %v", err) + } + previousTarget := filepath.Join(t.TempDir(), "previous-backend.jar") + if err := os.WriteFile(previousTarget, []byte("previous backend jar"), 0o640); err != nil { + t.Fatalf("write previous backend JAR: %v", err) + } + slotDirectory := t.TempDir() + slotJarPath := filepath.Join(slotDirectory, "backend-green.jar") + if err := os.Symlink(previousTarget, slotJarPath); err != nil { + t.Fatalf("create previous native backend slot link: %v", err) + } + request := Request{ + ArtifactPath: artifactPath, + ArtifactIdentity: testIdentity(artifactContent), + ReleasePath: "glory-soft-yms-20260815.jar", + SlotJarPath: slotJarPath, + PreviousSlotTarget: previousTarget, + UnitName: "yms-green.service", + Port: 8081, + HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health", + } + units := &fakeUnitManager{unit: systemd.Unit{ + Name: request.UnitName, + LoadState: "loaded", + ActiveState: inactiveState, + SubState: "dead", + }} + executor, err := New(store, coordinator, releaseStore, units, &http.Client{}) + if err != nil { + t.Fatalf("create native backend executor: %v", err) + } + executor.checker = &fakeActuatorChecker{ + waitReport: healthcheck.ActuatorReport{Status: "UP", Components: map[string]string{"db": "UP"}}, + checkReport: healthcheck.ActuatorReport{Status: "UP", Components: map[string]string{"db": "UP"}}, + checkReady: true, + } + return executor, store, releaseStore, units, request, previousTarget +} + +func createNativeTransaction(t *testing.T, store *transaction.Store, suffix string) transaction.Transaction { + t.Helper() + record, _, err := store.CreateTransaction(context.Background(), transaction.CreateRequest{ + ID: "native-backend-" + suffix, + IdempotencyKey: "native-backend-request-" + suffix, + Source: "test", + Service: "backend", + }) + if err != nil { + t.Fatalf("create native backend transaction: %v", err) + } + return record +} + +func transitionNativeToPrepared(t *testing.T, store *transaction.Store, transactionID string) { + t.Helper() + ctx := context.Background() + if _, err := store.Transition(ctx, transactionID, transaction.StateValidating, "test validating"); err != nil { + t.Fatalf("transition native transaction to validating: %v", err) + } + if _, err := store.Transition(ctx, transactionID, transaction.StatePrepared, "test prepared"); err != nil { + t.Fatalf("transition native transaction to prepared: %v", err) + } +} + +func testIdentity(content []byte) filestore.Identity { + digest := sha256.Sum256(content) + return filestore.Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} +} + +type fakeUnitManager struct { + mu sync.Mutex + unit systemd.Unit + startErr error + stopErr error + startCalls int + stopCalls int + startedName string +} + +func (m *fakeUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) { + m.mu.Lock() + defer m.mu.Unlock() + if name != m.unit.Name { + return systemd.Unit{}, systemd.ErrUnitNotFound + } + return m.unit, nil +} + +func (m *fakeUnitManager) Start(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.startCalls++ + m.startedName = name + if m.startErr != nil { + m.unit.ActiveState = failedState + m.unit.SubState = "failed" + return m.startErr + } + m.unit.ActiveState = activeState + m.unit.SubState = "running" + return nil +} + +func (m *fakeUnitManager) Stop(context.Context, string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.stopCalls++ + if m.stopErr != nil { + return m.stopErr + } + m.unit.ActiveState = inactiveState + m.unit.SubState = "dead" + return nil +} + +type fakeActuatorChecker struct { + waitReport healthcheck.ActuatorReport + waitErr error + checkReport healthcheck.ActuatorReport + checkReady bool + checkErr error +} + +func (c *fakeActuatorChecker) Wait(ctx context.Context, _ string, _ time.Duration, running healthcheck.RunningProbe) (healthcheck.ActuatorReport, error) { + isRunning, err := running(ctx) + if err != nil { + return healthcheck.ActuatorReport{}, err + } + if !isRunning { + return healthcheck.ActuatorReport{}, healthcheck.ErrWorkloadStopped + } + return c.waitReport, c.waitErr +} + +func (c *fakeActuatorChecker) Check(ctx context.Context, _ string, running healthcheck.RunningProbe) (healthcheck.ActuatorReport, bool, error) { + isRunning, err := running(ctx) + if err != nil { + return healthcheck.ActuatorReport{}, false, err + } + if !isRunning { + return healthcheck.ActuatorReport{}, false, healthcheck.ErrWorkloadStopped + } + return c.checkReport, c.checkReady, c.checkErr +} + +var _ systemd.Manager = (*fakeUnitManager)(nil) +var _ actuatorChecker = (*fakeActuatorChecker)(nil) diff --git a/internal/nativebackendexecutor/operations.go b/internal/nativebackendexecutor/operations.go new file mode 100644 index 0000000..337fcd0 --- /dev/null +++ b/internal/nativebackendexecutor/operations.go @@ -0,0 +1,307 @@ +package nativebackendexecutor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "yms-daemon/internal/filestore" + "yms-daemon/internal/healthcheck" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +type installJarOperation struct { + store *filestore.Store + sourcePath string + releasePath string + identity filestore.Identity +} + +func (o *installJarOperation) Apply(context.Context) error { + source, err := os.Open(o.sourcePath) + if err != nil { + return fmt.Errorf("open native backend JAR: %w", err) + } + defer source.Close() + _, err = o.store.Commit(o.releasePath, source, o.identity) + return err +} + +func (o *installJarOperation) Inspect(context.Context) (transaction.Inspection, error) { + file, found, err := o.store.Inspect(o.releasePath, o.identity) + if errors.Is(err, filestore.ErrDestinationConflict) { + return transaction.Inspection{Status: transaction.InspectionUnknown}, nil + } + if err != nil { + return transaction.Inspection{}, err + } + if !found { + return transaction.Inspection{Status: transaction.InspectionNotApplied}, nil + } + return transaction.Inspection{Status: transaction.InspectionApplied, Result: resultJSON(file)}, nil +} + +type slotLinkOperation struct { + path string + desiredTarget string + previousTarget string +} + +func (o *slotLinkOperation) Apply(ctx context.Context) error { + inspection, err := o.Inspect(ctx) + if err != nil { + return err + } + switch inspection.Status { + case transaction.InspectionApplied: + return nil + case transaction.InspectionNotApplied: + case transaction.InspectionUnknown: + return fmt.Errorf("native backend slot link %s does not match the recorded previous target", o.path) + default: + return fmt.Errorf("native backend slot link %s returned invalid inspection status %q", o.path, inspection.Status) + } + + parent := filepath.Dir(o.path) + parentInfo, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("inspect native backend slot directory %s: %w", parent, err) + } + if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("native backend slot parent is not a direct directory: %s", parent) + } + if o.desiredTarget == "" { + if err := os.Remove(o.path); err != nil { + return fmt.Errorf("remove native backend slot link %s: %w", o.path, err) + } + return syncDirectory(parent) + } + targetInfo, err := os.Lstat(o.desiredTarget) + if err != nil { + return fmt.Errorf("inspect native backend slot target %s: %w", o.desiredTarget, err) + } + if !targetInfo.Mode().IsRegular() || targetInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("native backend slot target is not a regular file: %s", o.desiredTarget) + } + + temporary, err := os.CreateTemp(parent, ".slot-link-*") + if err != nil { + return fmt.Errorf("reserve native backend slot link path: %w", err) + } + temporaryPath := temporary.Name() + if err := temporary.Close(); err != nil { + _ = os.Remove(temporaryPath) + return fmt.Errorf("close native backend slot link reservation: %w", err) + } + if err := os.Remove(temporaryPath); err != nil { + return fmt.Errorf("remove native backend slot link reservation: %w", err) + } + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if err := os.Symlink(o.desiredTarget, temporaryPath); err != nil { + return fmt.Errorf("create native backend slot link: %w", err) + } + if err := os.Rename(temporaryPath, o.path); err != nil { + return fmt.Errorf("replace native backend slot link %s: %w", o.path, err) + } + committed = true + return syncDirectory(parent) +} + +func (o *slotLinkOperation) Inspect(context.Context) (transaction.Inspection, error) { + info, err := os.Lstat(o.path) + if errors.Is(err, os.ErrNotExist) { + if o.desiredTarget == "" { + if err := syncDirectory(filepath.Dir(o.path)); err != nil { + return transaction.Inspection{}, err + } + return transaction.Inspection{Status: transaction.InspectionApplied, Result: linkResult(o.path, "")}, nil + } + if o.previousTarget == "" { + return transaction.Inspection{Status: transaction.InspectionNotApplied}, nil + } + return transaction.Inspection{Status: transaction.InspectionUnknown}, nil + } + if err != nil { + return transaction.Inspection{}, err + } + if info.Mode()&os.ModeSymlink == 0 { + return transaction.Inspection{Status: transaction.InspectionUnknown}, nil + } + target, err := os.Readlink(o.path) + if err != nil { + return transaction.Inspection{}, err + } + result := linkResult(o.path, target) + if target == o.desiredTarget { + if err := syncDirectory(filepath.Dir(o.path)); err != nil { + return transaction.Inspection{}, err + } + return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil + } + if target == o.previousTarget { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + } + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil +} + +type unitStartOperation struct { + units systemd.Manager + name string +} + +func (o *unitStartOperation) Apply(ctx context.Context) error { + return o.units.Start(ctx, o.name) +} + +func (o *unitStartOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { + unit, err := o.units.Inspect(ctx, o.name) + if err != nil { + return transaction.Inspection{}, err + } + result := unitResult(unit) + switch unit.ActiveState { + case activeState: + return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil + case inactiveState, failedState: + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + default: + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil + } +} + +type unitStopOperation struct { + units systemd.Manager + name string +} + +func (o *unitStopOperation) Apply(ctx context.Context) error { + return o.units.Stop(ctx, o.name) +} + +func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { + unit, err := o.units.Inspect(ctx, o.name) + if err != nil { + return transaction.Inspection{}, err + } + result := unitResult(unit) + switch unit.ActiveState { + case inactiveState, failedState: + return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil + case activeState: + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + default: + return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil + } +} + +type healthOperation struct { + units systemd.Manager + checker actuatorChecker + unitName string + endpoint string + timeout time.Duration + + mu sync.Mutex + confirmedReport healthcheck.ActuatorReport + confirmed bool +} + +func (o *healthOperation) Apply(ctx context.Context) error { + report, err := o.checker.Wait(ctx, o.endpoint, o.timeout, o.running) + if err != nil { + return err + } + o.mu.Lock() + o.confirmedReport = report + o.confirmed = true + o.mu.Unlock() + return nil +} + +func (o *healthOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { + o.mu.Lock() + if o.confirmed { + report := o.confirmedReport + o.mu.Unlock() + return healthInspection(report, true, nil) + } + o.mu.Unlock() + report, ready, err := o.checker.Check(ctx, o.endpoint, o.running) + return healthInspection(report, ready, err) +} + +func (o *healthOperation) running(ctx context.Context) (bool, error) { + unit, err := o.units.Inspect(ctx, o.unitName) + if errors.Is(err, systemd.ErrUnitNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return unit.ActiveState == activeState, nil +} + +func healthInspection(report healthcheck.ActuatorReport, ready bool, err error) (transaction.Inspection, error) { + result := resultJSON(report) + if errors.Is(err, healthcheck.ErrWorkloadStopped) { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + } + if err != nil || !ready { + return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil + } + return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil +} + +func linkResult(path, target string) json.RawMessage { + return resultJSON(struct { + Path string `json:"path"` + Target string `json:"target"` + }{path, target}) +} + +func unitResult(unit systemd.Unit) json.RawMessage { + return resultJSON(struct { + Name string `json:"name"` + LoadState string `json:"loadState"` + ActiveState string `json:"activeState"` + SubState string `json:"subState"` + }{unit.Name, unit.LoadState, unit.ActiveState, unit.SubState}) +} + +func resultJSON(value any) json.RawMessage { + payload, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("marshal internal step result: %v", err)) + } + return payload +} + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return fmt.Errorf("open directory for flush: %w", err) + } + syncErr := directory.Sync() + closeErr := directory.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("flush directory: %w", err) + } + return nil +} + +var _ transaction.Operation = (*installJarOperation)(nil) +var _ transaction.Operation = (*slotLinkOperation)(nil) +var _ transaction.Operation = (*unitStartOperation)(nil) +var _ transaction.Operation = (*unitStopOperation)(nil) +var _ transaction.Operation = (*healthOperation)(nil) diff --git a/internal/runtimepaths/paths.go b/internal/runtimepaths/paths.go new file mode 100644 index 0000000..65bb167 --- /dev/null +++ b/internal/runtimepaths/paths.go @@ -0,0 +1,16 @@ +// Package runtimepaths contains the server paths frozen for the daemon service. +package runtimepaths + +const ( + Socket = "/run/yms-daemon/yms-daemon.sock" + Lock = "/run/yms-daemon/yms-daemon.lock" + Database = "/var/lib/yms-daemon/yms-daemon.db" + WorkRoot = "/var/lib/yms-daemon/work" + Log = "/var/log/yms-daemon/yms-daemon.log" +) + +const ( + HostNginxConfig = "/etc/nginx/nginx.conf" + HostNginxExecutable = "/usr/sbin/nginx" + HostNginxService = "nginx.service" +) diff --git a/internal/systemd/manager.go b/internal/systemd/manager.go new file mode 100644 index 0000000..7f3013d --- /dev/null +++ b/internal/systemd/manager.go @@ -0,0 +1,24 @@ +// Package systemd defines the exact systemd boundary used by native executors. +package systemd + +import ( + "context" + "errors" +) + +var ErrUnitNotFound = errors.New("systemd unit not found") + +// Unit is the systemd state required for idempotent start and stop inspection. +type Unit struct { + Name string + LoadState string + ActiveState string + SubState string +} + +// Manager performs direct systemd operations without invoking a shell. +type Manager interface { + Inspect(context.Context, string) (Unit, error) + Start(context.Context, string) error + Stop(context.Context, string) error +} diff --git a/internal/systemd/systemctl.go b/internal/systemd/systemctl.go new file mode 100644 index 0000000..aef9e2f --- /dev/null +++ b/internal/systemd/systemctl.go @@ -0,0 +1,134 @@ +package systemd + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +const ( + loadStateProperty = "LoadState" + activeStateProperty = "ActiveState" + subStateProperty = "SubState" + loadedState = "loaded" + notFoundState = "not-found" +) + +// Systemctl invokes one exact systemctl executable directly, never through a shell. +type Systemctl struct { + executable string +} + +// NewSystemctl requires the absolute executable path supplied by local daemon configuration. +func NewSystemctl(executable string) (*Systemctl, error) { + if !filepath.IsAbs(executable) { + return nil, errors.New("systemctl executable path must be absolute") + } + return &Systemctl{executable: executable}, nil +} + +func (s *Systemctl) Inspect(ctx context.Context, unitName string) (Unit, error) { + if err := validateUnitName(unitName); err != nil { + return Unit{}, err + } + command := exec.CommandContext(ctx, s.executable, + "show", + "--no-pager", + "--property="+loadStateProperty, + "--property="+activeStateProperty, + "--property="+subStateProperty, + "--", + unitName, + ) + output, err := command.CombinedOutput() + if err != nil { + return Unit{}, commandError("inspect systemd unit "+unitName, output, err) + } + unit, err := parseUnitProperties(unitName, output) + if err != nil { + return Unit{}, err + } + if unit.LoadState == notFoundState { + return Unit{}, fmt.Errorf("inspect systemd unit %s: %w", unitName, ErrUnitNotFound) + } + if unit.LoadState != loadedState { + return Unit{}, fmt.Errorf("systemd unit %s has unsupported load state %q", unitName, unit.LoadState) + } + return unit, nil +} + +func (s *Systemctl) Start(ctx context.Context, unitName string) error { + return s.changeState(ctx, "start", unitName) +} + +func (s *Systemctl) Stop(ctx context.Context, unitName string) error { + return s.changeState(ctx, "stop", unitName) +} + +func (s *Systemctl) changeState(ctx context.Context, action, unitName string) error { + if err := validateUnitName(unitName); err != nil { + return err + } + output, err := exec.CommandContext(ctx, s.executable, action, "--", unitName).CombinedOutput() + if err != nil { + return commandError(action+" systemd unit "+unitName, output, err) + } + return nil +} + +func parseUnitProperties(unitName string, output []byte) (Unit, error) { + values := make(map[string]string, 3) + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + key, value, found := strings.Cut(line, "=") + if !found { + return Unit{}, fmt.Errorf("decode systemd unit %s property line %q", unitName, line) + } + switch key { + case loadStateProperty, activeStateProperty, subStateProperty: + if _, duplicate := values[key]; duplicate { + return Unit{}, fmt.Errorf("decode systemd unit %s duplicate property %s", unitName, key) + } + values[key] = value + default: + return Unit{}, fmt.Errorf("decode systemd unit %s unexpected property %q", unitName, key) + } + } + if err := scanner.Err(); err != nil { + return Unit{}, fmt.Errorf("decode systemd unit %s properties: %w", unitName, err) + } + for _, key := range []string{loadStateProperty, activeStateProperty, subStateProperty} { + if _, found := values[key]; !found { + return Unit{}, fmt.Errorf("decode systemd unit %s missing property %s", unitName, key) + } + } + return Unit{ + Name: unitName, + LoadState: values[loadStateProperty], + ActiveState: values[activeStateProperty], + SubState: values[subStateProperty], + }, nil +} + +func validateUnitName(unitName string) error { + if unitName == "" || strings.TrimSpace(unitName) != unitName { + return errors.New("exact systemd unit name is required") + } + return nil +} + +func commandError(action string, output []byte, err error) error { + detail := strings.TrimSpace(string(output)) + if detail == "" { + return fmt.Errorf("%s: %w", action, err) + } + return fmt.Errorf("%s: %w: %s", action, err, detail) +} + +var _ Manager = (*Systemctl)(nil) diff --git a/internal/systemd/systemctl_test.go b/internal/systemd/systemctl_test.go new file mode 100644 index 0000000..60743e0 --- /dev/null +++ b/internal/systemd/systemctl_test.go @@ -0,0 +1,50 @@ +package systemd + +import ( + "testing" +) + +func TestParseUnitPropertiesRequiresExactProperties(t *testing.T) { + t.Parallel() + unit, err := parseUnitProperties("yms-green.service", []byte("LoadState=loaded\nActiveState=inactive\nSubState=dead\n")) + if err != nil { + t.Fatalf("parse systemd properties: %v", err) + } + if unit.Name != "yms-green.service" || unit.LoadState != "loaded" || unit.ActiveState != "inactive" || unit.SubState != "dead" { + t.Fatalf("unexpected unit: %+v", unit) + } + + invalid := [][]byte{ + []byte("LoadState=loaded\nActiveState=inactive\n"), + []byte("LoadState=loaded\nActiveState=inactive\nSubState=dead\nDescription=YMS\n"), + []byte("LoadState=loaded\nLoadState=loaded\nActiveState=inactive\nSubState=dead\n"), + } + for _, output := range invalid { + if _, err := parseUnitProperties("yms-green.service", output); err == nil { + t.Fatalf("expected exact property validation failure for %q", output) + } + } +} + +func TestNewSystemctlRequiresAbsoluteExecutable(t *testing.T) { + t.Parallel() + if _, err := NewSystemctl("systemctl"); err == nil { + t.Fatal("expected relative executable path rejection") + } + manager, err := NewSystemctl("/usr/bin/systemctl") + if err != nil || manager.executable != "/usr/bin/systemctl" { + t.Fatalf("unexpected manager: manager=%+v err=%v", manager, err) + } +} + +func TestValidateUnitNamePreservesOpaqueValue(t *testing.T) { + t.Parallel() + if err := validateUnitName("backend-green.service"); err != nil { + t.Fatalf("validate exact unit name: %v", err) + } + for _, name := range []string{"", " backend-green.service", "backend-green.service "} { + if err := validateUnitName(name); err == nil { + t.Fatalf("expected unit name rejection: %q", name) + } + } +} diff --git a/internal/updatepackage/backend_native.go b/internal/updatepackage/backend_native.go new file mode 100644 index 0000000..0ea91d7 --- /dev/null +++ b/internal/updatepackage/backend_native.go @@ -0,0 +1,483 @@ +// Package updatepackage reads the exact repack ZIP format currently emitted by deploy. +package updatepackage + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "yms-daemon/internal/filestore" +) + +const ( + manifestName = "artifact-selection.json" + backendArtifactKind = "BACKEND" + nativeArtifactType = "native" + maximumManifestBytes = 1 << 20 +) + +// BackendNativePackage is an opened repack ZIP containing one exact native backend artifact. +type BackendNativePackage struct { + archive *zip.ReadCloser + artifactEntry *zip.File + + PackagePath string + PackageSHA256 string + CustomerCode string + VersionID string + ArtifactID int64 + FileName string + Identity filestore.Identity +} + +// OpenBackendNative validates the ZIP structure and selects one explicitly declared native backend artifact. +func OpenBackendNative(packagePath string) (*BackendNativePackage, error) { + if err := validateAbsoluteRegularFile(packagePath, "update package"); err != nil { + return nil, err + } + packageDigest, err := hashFile(packagePath) + if err != nil { + return nil, err + } + + archive, err := zip.OpenReader(packagePath) + if err != nil { + return nil, fmt.Errorf("open update package ZIP %s: %w", packagePath, err) + } + closeOnError := func(cause error) (*BackendNativePackage, error) { + return nil, errors.Join(cause, archive.Close()) + } + + entries, err := validateEntries(archive.File) + if err != nil { + return closeOnError(err) + } + manifestEntry, found := entries[manifestName] + if !found { + return closeOnError(errors.New("update package is missing artifact-selection.json")) + } + manifest, err := decodeManifest(manifestEntry) + if err != nil { + return closeOnError(err) + } + selected, err := selectNativeBackend(manifest.BackendArtifacts) + if err != nil { + return closeOnError(err) + } + if err := validateRootFileName(selected.FileName); err != nil { + return closeOnError(fmt.Errorf("invalid backendArtifacts.fileName: %w", err)) + } + artifactEntry, found := entries[selected.FileName] + if !found { + return closeOnError(fmt.Errorf("native backend artifact declared by backendArtifacts.fileName is missing: %s", selected.FileName)) + } + if artifactEntry.FileInfo().IsDir() { + return closeOnError(fmt.Errorf("native backend artifact is not a file: %s", selected.FileName)) + } + identity := filestore.Identity{ + Size: int64(artifactEntry.UncompressedSize64), + SHA256: selected.SHA256, + } + if err := identity.Validate(); err != nil { + return closeOnError(fmt.Errorf("invalid backendArtifacts.sha256: %w", err)) + } + + return &BackendNativePackage{ + archive: archive, + artifactEntry: artifactEntry, + PackagePath: packagePath, + PackageSHA256: packageDigest, + CustomerCode: manifest.CustomerCode, + VersionID: manifest.VersionID, + ArtifactID: selected.ID, + FileName: selected.FileName, + Identity: identity, + }, nil +} + +// Close releases the opened ZIP file. +func (p *BackendNativePackage) Close() error { + if p == nil || p.archive == nil { + return nil + } + archive := p.archive + p.archive = nil + p.artifactEntry = nil + return archive.Close() +} + +// ExtractArtifact writes the selected JAR to destination and verifies size and SHA-256 before publishing it. +func (p *BackendNativePackage) ExtractArtifact(destination string) error { + if p == nil || p.archive == nil || p.artifactEntry == nil { + return errors.New("native backend update package is not open") + } + if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination { + return errors.New("native backend extraction destination must be a clean absolute path") + } + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o750); err != nil { + return fmt.Errorf("create native backend extraction directory: %w", err) + } + parentInfo, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("inspect native backend extraction directory: %w", err) + } + if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("native backend extraction parent is not a direct directory: %s", parent) + } + + source, err := p.artifactEntry.Open() + if err != nil { + return fmt.Errorf("open native backend artifact %s: %w", p.FileName, err) + } + temporary, err := os.CreateTemp(parent, ".backend-jar-*") + if err != nil { + _ = source.Close() + return fmt.Errorf("create native backend extraction file: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o640); err != nil { + _ = source.Close() + _ = temporary.Close() + return fmt.Errorf("set native backend extraction permissions: %w", err) + } + + digest := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(temporary, digest), source) + closeSourceErr := source.Close() + if err := errors.Join(copyErr, closeSourceErr); err != nil { + _ = temporary.Close() + return fmt.Errorf("extract native backend artifact: %w", err) + } + if written != p.Identity.Size { + _ = temporary.Close() + return fmt.Errorf("native backend artifact size mismatch: got %d, want %d", written, p.Identity.Size) + } + actualDigest := hex.EncodeToString(digest.Sum(nil)) + if !strings.EqualFold(actualDigest, p.Identity.SHA256) { + _ = temporary.Close() + return fmt.Errorf("native backend artifact SHA-256 mismatch: got %s, want %s", actualDigest, p.Identity.SHA256) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("flush native backend extraction file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close native backend extraction file: %w", err) + } + if err := os.Rename(temporaryPath, destination); err != nil { + return fmt.Errorf("publish native backend extraction file: %w", err) + } + return syncDirectory(parent) +} + +type artifactSelectionManifest struct { + CustomerCode string `json:"customerCode"` + CustomerDisplayName *string `json:"customerDisplayName"` + VersionID string `json:"versionId"` + Items []string `json:"items"` + BackendArtifacts []manifestArtifact `json:"backendArtifacts"` + FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"` + NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"` + Remark *string `json:"remark"` +} + +type manifestArtifact struct { + ID int64 `json:"id"` + VersionCode *string `json:"versionCode"` + ArtifactKind string `json:"artifactKind"` + Type string `json:"type"` + SelectedType string `json:"selectedType"` + Platform *string `json:"platform"` + FileName string `json:"fileName"` + FilePath *string `json:"filePath"` + SHA256 string `json:"sha256"` + ImageRef *string `json:"imageRef"` +} + +func decodeManifest(entry *zip.File) (artifactSelectionManifest, error) { + if entry.FileInfo().IsDir() { + return artifactSelectionManifest{}, errors.New("artifact-selection.json is not a file") + } + if entry.UncompressedSize64 > maximumManifestBytes { + return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit") + } + reader, err := entry.Open() + if err != nil { + return artifactSelectionManifest{}, fmt.Errorf("open artifact-selection.json: %w", err) + } + defer reader.Close() + + document, err := io.ReadAll(io.LimitReader(reader, maximumManifestBytes+1)) + if err != nil { + return artifactSelectionManifest{}, fmt.Errorf("read artifact-selection.json: %w", err) + } + if len(document) > maximumManifestBytes { + return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit") + } + if err := validateExactManifestJSON(document); err != nil { + return artifactSelectionManifest{}, fmt.Errorf("validate artifact-selection.json keys: %w", err) + } + + var manifest artifactSelectionManifest + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return artifactSelectionManifest{}, fmt.Errorf("decode artifact-selection.json: %w", err) + } + if err := ensureJSONEnd(decoder); err != nil { + return artifactSelectionManifest{}, err + } + if strings.TrimSpace(manifest.CustomerCode) == "" || strings.TrimSpace(manifest.VersionID) == "" { + return artifactSelectionManifest{}, errors.New("artifact-selection.json customerCode and versionId are required") + } + return manifest, nil +} + +var rootManifestKeys = map[string]struct{}{ + "customerCode": {}, + "customerDisplayName": {}, + "versionId": {}, + "items": {}, + "backendArtifacts": {}, + "frontendArtifacts": {}, + "nodeSsrArtifacts": {}, + "remark": {}, +} + +var artifactManifestKeys = map[string]struct{}{ + "id": {}, + "versionCode": {}, + "artifactKind": {}, + "type": {}, + "selectedType": {}, + "platform": {}, + "fileName": {}, + "filePath": {}, + "sha256": {}, + "imageRef": {}, +} + +func validateExactManifestJSON(document []byte) error { + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.UseNumber() + if err := validateObject(decoder, "", rootManifestKeys, true); err != nil { + return err + } + return ensureJSONEnd(decoder) +} + +func validateObject(decoder *json.Decoder, objectPath string, allowed map[string]struct{}, root bool) error { + token, err := decoder.Token() + if err != nil { + return err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return fmt.Errorf("%s must be a JSON object", displayJSONPath(objectPath)) + } + seen := make(map[string]struct{}, len(allowed)) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("%s contains a non-string key", displayJSONPath(objectPath)) + } + fieldPath := key + if objectPath != "" { + fieldPath = objectPath + "." + key + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("duplicate JSON field %s", fieldPath) + } + seen[key] = struct{}{} + if _, known := allowed[key]; !known { + return fmt.Errorf("unknown JSON field %s", fieldPath) + } + if root && (key == "backendArtifacts" || key == "frontendArtifacts" || key == "nodeSsrArtifacts") { + if err := validateArtifactArray(decoder, fieldPath); err != nil { + return err + } + continue + } + if err := consumeJSONValue(decoder, fieldPath); err != nil { + return err + } + } + _, err = decoder.Token() + return err +} + +func validateArtifactArray(decoder *json.Decoder, arrayPath string) error { + token, err := decoder.Token() + if err != nil { + return err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { + return fmt.Errorf("%s must be a JSON array", arrayPath) + } + index := 0 + for decoder.More() { + if err := validateObject(decoder, fmt.Sprintf("%s[%d]", arrayPath, index), artifactManifestKeys, false); err != nil { + return err + } + index++ + } + _, err = decoder.Token() + return err +} + +func consumeJSONValue(decoder *json.Decoder, valuePath string) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, composite := token.(json.Delim) + if !composite { + return nil + } + switch delimiter { + case '[': + for decoder.More() { + if err := consumeJSONValue(decoder, valuePath); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key := keyToken.(string) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("duplicate JSON field %s.%s", valuePath, key) + } + seen[key] = struct{}{} + if err := consumeJSONValue(decoder, valuePath+"."+key); err != nil { + return err + } + } + _, err = decoder.Token() + return err + default: + return fmt.Errorf("unexpected JSON delimiter %q at %s", delimiter, valuePath) + } +} + +func displayJSONPath(value string) string { + if value == "" { + return "artifact-selection.json" + } + return value +} + +func ensureJSONEnd(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("artifact-selection.json contains multiple JSON values") + } + return fmt.Errorf("decode artifact-selection.json trailing content: %w", err) + } + return nil +} + +func selectNativeBackend(artifacts []manifestArtifact) (manifestArtifact, error) { + var selected manifestArtifact + count := 0 + for _, artifact := range artifacts { + if artifact.ArtifactKind == backendArtifactKind && artifact.Type == nativeArtifactType && artifact.SelectedType == nativeArtifactType { + selected = artifact + count++ + } + } + if count != 1 { + return manifestArtifact{}, fmt.Errorf("backendArtifacts must contain exactly one BACKEND native selection, got %d", count) + } + if selected.ID <= 0 || selected.FileName == "" || selected.SHA256 == "" { + return manifestArtifact{}, errors.New("selected native backend artifact requires id, fileName and sha256") + } + return selected, nil +} + +func validateEntries(files []*zip.File) (map[string]*zip.File, error) { + entries := make(map[string]*zip.File, len(files)) + for _, file := range files { + name := file.Name + if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") { + return nil, fmt.Errorf("update package contains unsafe ZIP entry: %q", name) + } + if file.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("update package contains symbolic link entry: %s", name) + } + if _, duplicate := entries[name]; duplicate { + return nil, fmt.Errorf("update package contains duplicate ZIP entry: %s", name) + } + entries[name] = file + } + return entries, nil +} + +func validateRootFileName(name string) error { + if name == "" || strings.ContainsAny(name, "/\\") || path.Clean(name) != name || name == "." || name == ".." { + return fmt.Errorf("expected one exact ZIP root file name, got %q", name) + } + return nil +} + +func validateAbsoluteRegularFile(filePath string, description string) error { + if !filepath.IsAbs(filePath) || filepath.Clean(filePath) != filePath { + return fmt.Errorf("%s path must be a clean absolute path", description) + } + info, err := os.Lstat(filePath) + if err != nil { + return fmt.Errorf("inspect %s %s: %w", description, filePath, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is not a direct regular file: %s", description, filePath) + } + return nil +} + +func hashFile(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("open update package for SHA-256: %w", err) + } + digest := sha256.New() + _, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return "", fmt.Errorf("hash update package: %w", err) + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return fmt.Errorf("open extraction directory for flush: %w", err) + } + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("flush extraction directory: %w", err) + } + return nil +} diff --git a/internal/updatepackage/backend_native_test.go b/internal/updatepackage/backend_native_test.go new file mode 100644 index 0000000..feae6fd --- /dev/null +++ b/internal/updatepackage/backend_native_test.go @@ -0,0 +1,195 @@ +package updatepackage + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOpenAndExtractNativeBackend(t *testing.T) { + jar := []byte("native backend JAR") + packagePath := writeBackendPackage(t, jar, nil) + + updatePackage, err := OpenBackendNative(packagePath) + if err != nil { + t.Fatalf("open native backend package: %v", err) + } + defer updatePackage.Close() + if updatePackage.CustomerCode != "customer-01" || updatePackage.VersionID != "V1.1.8" || updatePackage.ArtifactID != 42 { + t.Fatalf("unexpected package metadata: %+v", updatePackage) + } + if updatePackage.Identity.Size != int64(len(jar)) || updatePackage.PackageSHA256 == "" { + t.Fatalf("unexpected package identity: package=%s artifact=%+v", updatePackage.PackageSHA256, updatePackage.Identity) + } + + destination := filepath.Join(t.TempDir(), "incoming", "backend.jar") + if err := updatePackage.ExtractArtifact(destination); err != nil { + t.Fatalf("extract native backend artifact: %v", err) + } + actual, err := os.ReadFile(destination) + if err != nil || !bytes.Equal(actual, jar) { + t.Fatalf("unexpected extracted artifact: content=%q err=%v", actual, err) + } +} + +func TestOpenRejectsUnknownManifestField(t *testing.T) { + jar := []byte("native backend JAR") + packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) { + manifest["VersionID"] = "wrong-case" + }) + _, err := OpenBackendNative(packagePath) + if err == nil || !strings.Contains(err.Error(), "VersionID") { + t.Fatalf("expected unknown manifest field rejection, got %v", err) + } +} + +func TestOpenRejectsMissingDeclaredArtifact(t *testing.T) { + jar := []byte("native backend JAR") + packagePath := writePackageEntries(t, map[string][]byte{ + manifestName: manifestJSON(t, jar, func(manifest map[string]any) { + artifacts := manifest["backendArtifacts"].([]any) + artifacts[0].(map[string]any)["fileName"] = "missing.jar" + }), + }) + _, err := OpenBackendNative(packagePath) + if err == nil || !strings.Contains(err.Error(), "missing.jar") { + t.Fatalf("expected missing artifact rejection, got %v", err) + } +} + +func TestExtractRejectsArtifactDigestMismatch(t *testing.T) { + jar := []byte("native backend JAR") + packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) { + artifacts := manifest["backendArtifacts"].([]any) + artifacts[0].(map[string]any)["sha256"] = strings.Repeat("0", 64) + }) + updatePackage, err := OpenBackendNative(packagePath) + if err != nil { + t.Fatalf("open package before digest verification: %v", err) + } + defer updatePackage.Close() + destination := filepath.Join(t.TempDir(), "backend.jar") + if err := updatePackage.ExtractArtifact(destination); err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") { + t.Fatalf("expected artifact digest mismatch, got %v", err) + } + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("digest mismatch published artifact: %v", err) + } +} + +func TestOpenRejectsUnsafeAndDuplicateEntries(t *testing.T) { + jar := []byte("native backend JAR") + manifest := manifestJSON(t, jar, nil) + + t.Run("unsafe", func(t *testing.T) { + packagePath := writePackageEntries(t, map[string][]byte{ + manifestName: manifest, + "backend.jar": jar, + "../escape": []byte("escape"), + }) + if _, err := OpenBackendNative(packagePath); err == nil { + t.Fatal("expected unsafe ZIP entry rejection") + } + }) + + t.Run("duplicate", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "package.zip") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create duplicate package: %v", err) + } + writer := zip.NewWriter(file) + for _, content := range [][]byte{manifest, manifest} { + entry, err := writer.Create(manifestName) + if err != nil { + t.Fatalf("create duplicate manifest entry: %v", err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write duplicate manifest entry: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close duplicate package writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close duplicate package: %v", err) + } + if _, err := OpenBackendNative(path); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate ZIP entry rejection, got %v", err) + } + }) +} + +func writeBackendPackage(t *testing.T, jar []byte, modify func(map[string]any)) string { + t.Helper() + return writePackageEntries(t, map[string][]byte{ + manifestName: manifestJSON(t, jar, modify), + "backend.jar": jar, + }) +} + +func manifestJSON(t *testing.T, jar []byte, modify func(map[string]any)) []byte { + t.Helper() + digest := sha256.Sum256(jar) + manifest := map[string]any{ + "customerCode": "customer-01", + "customerDisplayName": "Customer 01", + "versionId": "V1.1.8", + "items": []string{"deploy-sync.sh"}, + "backendArtifacts": []any{map[string]any{ + "id": int64(42), + "versionCode": "V1.1.8", + "artifactKind": backendArtifactKind, + "type": nativeArtifactType, + "selectedType": nativeArtifactType, + "platform": nil, + "fileName": "backend.jar", + "filePath": "/archive/backend.jar", + "sha256": hex.EncodeToString(digest[:]), + "imageRef": nil, + }}, + "frontendArtifacts": []any{}, + "nodeSsrArtifacts": []any{}, + "remark": nil, + } + if modify != nil { + modify(manifest) + } + content, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("encode manifest: %v", err) + } + return content +} + +func writePackageEntries(t *testing.T, entries map[string][]byte) string { + t.Helper() + packagePath := filepath.Join(t.TempDir(), "package.zip") + file, err := os.Create(packagePath) + if err != nil { + t.Fatalf("create package: %v", err) + } + writer := zip.NewWriter(file) + for name, content := range entries { + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create package entry %s: %v", name, err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write package entry %s: %v", name, err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close package writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close package: %v", err) + } + return packagePath +} diff --git a/internal/updatepackage/direct_native.go b/internal/updatepackage/direct_native.go new file mode 100644 index 0000000..85c1ceb --- /dev/null +++ b/internal/updatepackage/direct_native.go @@ -0,0 +1,152 @@ +package updatepackage + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "yms-daemon/internal/filestore" +) + +// DirectNativeJAR is one JAR supplied directly by Jenkins or an operator. +// FileName is treated as opaque text; no version is parsed from it. +type DirectNativeJAR struct { + Path string + SHA256 string + FileName string + Identity filestore.Identity +} + +// OpenDirectNativeJAR validates the direct file, verifies every ZIP entry and +// records the immutable identity used by the transaction. +func OpenDirectNativeJAR(jarPath string) (DirectNativeJAR, error) { + if err := validateAbsoluteRegularFile(jarPath, "native backend JAR"); err != nil { + return DirectNativeJAR{}, err + } + fileName := filepath.Base(jarPath) + if filepath.Ext(fileName) != ".jar" { + return DirectNativeJAR{}, fmt.Errorf("native backend JAR file name must end with .jar: %s", fileName) + } + if err := verifyJARArchive(jarPath); err != nil { + return DirectNativeJAR{}, err + } + identity, err := identifyFile(jarPath) + if err != nil { + return DirectNativeJAR{}, err + } + return DirectNativeJAR{ + Path: jarPath, + SHA256: identity.SHA256, + FileName: fileName, + Identity: identity, + }, nil +} + +// CopyArtifact copies the exact JAR into transaction storage and rejects a +// source file that changes after OpenDirectNativeJAR returns. +func (j DirectNativeJAR) CopyArtifact(destination string) error { + if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination { + return errors.New("native backend JAR destination must be a clean absolute path") + } + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o750); err != nil { + return fmt.Errorf("create native backend JAR destination directory: %w", err) + } + parentInfo, err := os.Lstat(parent) + if err != nil { + return fmt.Errorf("inspect native backend JAR destination directory: %w", err) + } + if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("native backend JAR destination parent is not a direct directory: %s", parent) + } + + source, err := os.Open(j.Path) + if err != nil { + return fmt.Errorf("open native backend JAR: %w", err) + } + temporary, err := os.CreateTemp(parent, ".backend-jar-*") + if err != nil { + _ = source.Close() + return fmt.Errorf("create native backend JAR transaction file: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o640); err != nil { + _ = source.Close() + _ = temporary.Close() + return fmt.Errorf("set native backend JAR transaction file permissions: %w", err) + } + + digest := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(temporary, digest), source) + closeSourceErr := source.Close() + if err := errors.Join(copyErr, closeSourceErr); err != nil { + _ = temporary.Close() + return fmt.Errorf("copy native backend JAR: %w", err) + } + actualDigest := hex.EncodeToString(digest.Sum(nil)) + if written != j.Identity.Size || !strings.EqualFold(actualDigest, j.Identity.SHA256) { + _ = temporary.Close() + return errors.New("native backend JAR changed while entering the transaction") + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("flush native backend JAR transaction file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close native backend JAR transaction file: %w", err) + } + if err := verifyJARArchive(temporaryPath); err != nil { + return fmt.Errorf("verify copied native backend JAR: %w", err) + } + if err := os.Rename(temporaryPath, destination); err != nil { + return fmt.Errorf("publish native backend JAR transaction file: %w", err) + } + return syncDirectory(parent) +} + +func verifyJARArchive(jarPath string) error { + archive, err := zip.OpenReader(jarPath) + if err != nil { + return fmt.Errorf("open native backend JAR %s: %w", jarPath, err) + } + defer archive.Close() + if len(archive.File) == 0 { + return errors.New("native backend JAR contains no ZIP entries") + } + for _, entry := range archive.File { + if entry.FileInfo().IsDir() { + continue + } + reader, err := entry.Open() + if err != nil { + return fmt.Errorf("open native backend JAR entry %s: %w", entry.Name, err) + } + _, readErr := io.Copy(io.Discard, reader) + closeErr := reader.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return fmt.Errorf("verify native backend JAR entry %s: %w", entry.Name, err) + } + } + return nil +} + +func identifyFile(filePath string) (filestore.Identity, error) { + file, err := os.Open(filePath) + if err != nil { + return filestore.Identity{}, fmt.Errorf("open native backend JAR for identity: %w", err) + } + digest := sha256.New() + size, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return filestore.Identity{}, fmt.Errorf("identify native backend JAR: %w", err) + } + return filestore.Identity{Size: size, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil +} diff --git a/internal/updatepackage/direct_native_test.go b/internal/updatepackage/direct_native_test.go new file mode 100644 index 0000000..f788ead --- /dev/null +++ b/internal/updatepackage/direct_native_test.go @@ -0,0 +1,88 @@ +package updatepackage + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOpenAndCopyDirectNativeJAR(t *testing.T) { + jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("backend classes")) + jar, err := OpenDirectNativeJAR(jarPath) + if err != nil { + t.Fatalf("open direct native backend JAR: %v", err) + } + if jar.FileName != "glory-soft-yms.jar" || jar.SHA256 == "" || jar.Identity.Size <= 0 { + t.Fatalf("unexpected direct native backend JAR metadata: %+v", jar) + } + + destination := filepath.Join(t.TempDir(), "transaction", "backend.jar") + if err := jar.CopyArtifact(destination); err != nil { + t.Fatalf("copy direct native backend JAR: %v", err) + } + expected, err := os.ReadFile(jarPath) + if err != nil { + t.Fatalf("read source JAR: %v", err) + } + actual, err := os.ReadFile(destination) + if err != nil || !bytes.Equal(actual, expected) { + t.Fatalf("unexpected copied JAR: err=%v", err) + } +} + +func TestOpenDirectNativeJARRejectsInvalidArchive(t *testing.T) { + path := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + if err := os.WriteFile(path, []byte("not a JAR"), 0o600); err != nil { + t.Fatalf("write invalid JAR: %v", err) + } + if _, err := OpenDirectNativeJAR(path); err == nil { + t.Fatal("expected invalid direct JAR rejection") + } +} + +func TestCopyDirectNativeJARRejectsChangedSource(t *testing.T) { + jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("first")) + jar, err := OpenDirectNativeJAR(jarPath) + if err != nil { + t.Fatalf("open direct native backend JAR: %v", err) + } + replacement := writeDirectNativeJAR(t, "replacement.jar", []byte("second")) + content, err := os.ReadFile(replacement) + if err != nil { + t.Fatalf("read replacement JAR: %v", err) + } + if err := os.WriteFile(jarPath, content, 0o600); err != nil { + t.Fatalf("replace source JAR: %v", err) + } + err = jar.CopyArtifact(filepath.Join(t.TempDir(), "backend.jar")) + if err == nil || !strings.Contains(err.Error(), "changed") { + t.Fatalf("expected changed source rejection, got %v", err) + } +} + +func writeDirectNativeJAR(t *testing.T, fileName string, content []byte) string { + t.Helper() + jarPath := filepath.Join(t.TempDir(), fileName) + file, err := os.Create(jarPath) + if err != nil { + t.Fatalf("create direct JAR: %v", err) + } + writer := zip.NewWriter(file) + entry, err := writer.Create("BOOT-INF/classes/application.properties") + if err != nil { + t.Fatalf("create direct JAR entry: %v", err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write direct JAR entry: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close direct JAR writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close direct JAR: %v", err) + } + return jarPath +} diff --git a/main.go b/main.go index b3bb151..c9ac85b 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,251 @@ package main -// 命令入口会在事务内核稳定后接入。当前提交只构建和验证底层能力。 -func main() {} +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "yms-daemon/internal/backendupdate" + "yms-daemon/internal/daemonapi" + "yms-daemon/internal/daemonclient" + "yms-daemon/internal/daemonserver" + "yms-daemon/internal/deploymentconfig" + "yms-daemon/internal/filestore" + "yms-daemon/internal/hostnginx" + "yms-daemon/internal/logging" + "yms-daemon/internal/processlock" + "yms-daemon/internal/runtimepaths" + "yms-daemon/internal/systemd" + "yms-daemon/internal/transaction" +) + +const serviceBackend = "backend" + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Writer) int { + if len(arguments) == 0 { + writeUsage(stderr) + return 2 + } + switch arguments[0] { + case "serve": + if len(arguments) != 1 { + fmt.Fprintln(stderr, "serve does not accept arguments") + return 2 + } + if err := runServe(ctx); err != nil { + fmt.Fprintln(stderr, "yms-daemon serve failed:", err) + return 1 + } + return 0 + case "update": + request, err := parseUpdateArgs(arguments[1:], stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + var progress func(daemonapi.Response) + if !request.quite { + progress = func(event daemonapi.Response) { + writeUpdateProgress(stdout, event) + } + } + response, err := daemonclient.Update(ctx, runtimepaths.Socket, request.service, request.inputType, request.file, progress) + if err != nil { + if response.TransactionID != "" { + fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err) + } else { + fmt.Fprintln(stderr, "yms-daemon update failed:", err) + } + return 1 + } + if !request.quite { + fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State) + } + return 0 + case "restart": + request, err := parseRestartArgs(arguments[1:], stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + var progress func(daemonapi.Response) + if !request.quite { + progress = func(event daemonapi.Response) { + writeUpdateProgress(stdout, event) + } + } + response, err := daemonclient.Restart(ctx, runtimepaths.Socket, request.service, progress) + if err != nil { + if response.TransactionID != "" { + fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err) + } else { + fmt.Fprintln(stderr, "yms-daemon restart failed:", err) + } + return 1 + } + if !request.quite { + fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State) + } + return 0 + case "help", "-h", "--help": + writeUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "unknown command: %s\n", arguments[0]) + writeUsage(stderr) + return 2 + } +} + +type updateArguments struct { + service string + inputType string + file string + quite bool +} + +type restartArguments struct { + service string + quite bool +} + +func parseUpdateArgs(arguments []string, output io.Writer) (updateArguments, error) { + flags := flag.NewFlagSet("update", flag.ContinueOnError) + flags.SetOutput(output) + service := flags.String("service", "", "service to update") + file := flags.String("f", "", "repack ZIP path") + nativeJAR := flags.String("native-jar", "", "direct native backend JAR path") + quite := flags.Bool("quite", false, "suppress progress and successful result output") + if err := flags.Parse(arguments); err != nil { + return updateArguments{}, err + } + if flags.NArg() != 0 { + return updateArguments{}, errors.New("update does not accept positional arguments") + } + if *service != serviceBackend { + return updateArguments{}, errors.New("--service currently accepts only backend") + } + if (*file == "") == (*nativeJAR == "") { + return updateArguments{}, errors.New("exactly one of -f and --native-jar is required") + } + inputType := daemonapi.InputTypeRepackZIP + inputFile := *file + if *nativeJAR != "" { + inputType = daemonapi.InputTypeNativeJAR + inputFile = *nativeJAR + } + absoluteFile, err := filepath.Abs(inputFile) + if err != nil { + return updateArguments{}, fmt.Errorf("resolve update package path: %w", err) + } + info, err := os.Lstat(absoluteFile) + if err != nil { + return updateArguments{}, fmt.Errorf("inspect update package %s: %w", absoluteFile, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return updateArguments{}, fmt.Errorf("update package is not a direct regular file: %s", absoluteFile) + } + return updateArguments{service: *service, inputType: inputType, file: absoluteFile, quite: *quite}, nil +} + +func parseRestartArgs(arguments []string, output io.Writer) (restartArguments, error) { + flags := flag.NewFlagSet("restart", flag.ContinueOnError) + flags.SetOutput(output) + service := flags.String("service", "", "service to restart") + quite := flags.Bool("quite", false, "suppress progress and successful result output") + if err := flags.Parse(arguments); err != nil { + return restartArguments{}, err + } + if flags.NArg() != 0 { + return restartArguments{}, errors.New("restart does not accept positional arguments") + } + if *service != serviceBackend { + return restartArguments{}, errors.New("--service currently accepts only backend") + } + return restartArguments{service: *service, quite: *quite}, nil +} + +func runServe(ctx context.Context) (result error) { + logger, logCloser, err := logging.New(runtimepaths.Log) + if err != nil { + return err + } + defer func() { result = errors.Join(result, logCloser.Close()) }() + + lock, err := processlock.Acquire(runtimepaths.Lock) + if err != nil { + return err + } + defer func() { result = errors.Join(result, lock.Close()) }() + + config, err := deploymentconfig.Load(deploymentconfig.DefaultPath) + if err != nil { + return err + } + store, err := transaction.OpenStore(ctx, runtimepaths.Database) + if err != nil { + return err + } + defer func() { result = errors.Join(result, store.Close()) }() + coordinator, err := transaction.NewCoordinator(store, logger) + if err != nil { + return err + } + releaseStore, err := filestore.New(config.Backend.ReleaseDir) + if err != nil { + return err + } + units, err := systemd.NewSystemctl(config.Backend.SystemctlPath) + if err != nil { + return err + } + gateway, err := hostnginx.NewController( + runtimepaths.HostNginxConfig, + runtimepaths.HostNginxExecutable, + config.Backend.SystemctlPath, + runtimepaths.HostNginxService, + ) + if err != nil { + return err + } + httpClient := &http.Client{Timeout: 5 * time.Second} + updater, err := backendupdate.New(config, runtimepaths.WorkRoot, store, coordinator, releaseStore, units, gateway, httpClient, logger) + if err != nil { + return err + } + server, err := daemonserver.New(runtimepaths.Socket, updater, logger) + if err != nil { + return err + } + return server.Serve(ctx) +} + +func writeUsage(output io.Writer) { + fmt.Fprintln(output, "usage:") + fmt.Fprintln(output, " yms-daemon serve") + fmt.Fprintln(output, " yms-daemon update --service backend -f [--quite]") + fmt.Fprintln(output, " yms-daemon update --service backend --native-jar [--quite]") + fmt.Fprintln(output, " yms-daemon restart --service backend [--quite]") +} + +func writeUpdateProgress(output io.Writer, event daemonapi.Response) { + state := event.State + if state == "" { + state = "INFO" + } + fmt.Fprintf(output, "%-14s %s\n", state, event.Message) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8cc28c9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "yms-daemon/internal/daemonapi" +) + +func TestParseUpdateArgsAcceptsBackendAndResolvesFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "package.zip") + if err := os.WriteFile(file, []byte("zip"), 0o600); err != nil { + t.Fatalf("write update package: %v", err) + } + request, err := parseUpdateArgs([]string{"--service", "backend", "-f", file}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse backend update arguments: %v", err) + } + if request.service != "backend" || request.inputType != daemonapi.InputTypeRepackZIP || request.file != file { + t.Fatalf("unexpected backend update arguments: %+v", request) + } +} + +func TestParseUpdateArgsAcceptsDirectNativeJAR(t *testing.T) { + file := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + if err := os.WriteFile(file, []byte("jar"), 0o600); err != nil { + t.Fatalf("write native backend JAR: %v", err) + } + request, err := parseUpdateArgs([]string{"--service", "backend", "--native-jar", file}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse direct native backend update arguments: %v", err) + } + if request.service != "backend" || request.inputType != daemonapi.InputTypeNativeJAR || request.file != file { + t.Fatalf("unexpected direct native backend update arguments: %+v", request) + } +} + +func TestParseUpdateArgsAcceptsQuite(t *testing.T) { + file := filepath.Join(t.TempDir(), "glory-soft-yms.jar") + if err := os.WriteFile(file, []byte("jar"), 0o600); err != nil { + t.Fatalf("write native backend JAR: %v", err) + } + request, err := parseUpdateArgs([]string{"--service", "backend", "--native-jar", file, "--quite"}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse quiet direct update arguments: %v", err) + } + if !request.quite { + t.Fatal("--quite was not retained") + } +} + +func TestWriteUpdateProgress(t *testing.T) { + var output bytes.Buffer + writeUpdateProgress(&output, daemonapi.Response{State: "STARTING", Message: "Starting backend"}) + if output.String() != "STARTING Starting backend\n" { + t.Fatalf("unexpected update progress: %q", output.String()) + } +} + +func TestParseRestartArgs(t *testing.T) { + request, err := parseRestartArgs([]string{"--service", "backend", "--quite"}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse backend restart arguments: %v", err) + } + if request.service != serviceBackend || !request.quite { + t.Fatalf("unexpected backend restart arguments: %+v", request) + } +} + +func TestParseRestartArgsRejectsUnsupportedInput(t *testing.T) { + for name, arguments := range map[string][]string{ + "missing service": nil, + "other service": {"--service", "frontend"}, + "positional": {"--service", "backend", "extra"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseRestartArgs(arguments, &bytes.Buffer{}); err == nil { + t.Fatal("expected restart argument rejection") + } + }) + } +} + +func TestParseUpdateArgsRejectsIncompleteOrUnsupportedInput(t *testing.T) { + for name, arguments := range map[string][]string{ + "missing service": {"-f", "/tmp/package.zip"}, + "missing file": {"--service", "backend"}, + "other service": {"--service", "frontend", "-f", "/tmp/package.zip"}, + "positional": {"--service", "backend", "-f", "/tmp/package.zip", "extra"}, + "both inputs": {"--service", "backend", "-f", "/tmp/package.zip", "--native-jar", "/tmp/backend.jar"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseUpdateArgs(arguments, &bytes.Buffer{}); err == nil { + t.Fatal("expected update argument rejection") + } + }) + } +} + +func TestRunPrintsUsageWithoutCommand(t *testing.T) { + var stderr bytes.Buffer + if exitCode := run(context.Background(), nil, &bytes.Buffer{}, &stderr); exitCode != 2 { + t.Fatalf("unexpected exit code: %d", exitCode) + } + if stderr.Len() == 0 { + t.Fatal("missing usage output") + } +} diff --git a/packaging/etc/yms-daemon/yms-daemon.toml b/packaging/etc/yms-daemon/yms-daemon.toml new file mode 100644 index 0000000..25fe831 --- /dev/null +++ b/packaging/etc/yms-daemon/yms-daemon.toml @@ -0,0 +1,15 @@ +[backend] +type = "native" +release_dir = "/home/yms/lib/releases" +active_jar = "/home/yms/lib/glory-soft-yms.jar" +systemctl_path = "/bin/systemctl" + +[backend.slot.8080] +unit = "yms-backend@8080.service" +jar = "/home/yms/lib/glory-soft-yms-8080.jar" +health_endpoint = "http://127.0.0.1:8080/yms/actuator/health" + +[backend.slot.8081] +unit = "yms-backend@8081.service" +jar = "/home/yms/lib/glory-soft-yms-8081.jar" +health_endpoint = "http://127.0.0.1:8081/yms/actuator/health" diff --git a/packaging/systemd/yms-backend@.service b/packaging/systemd/yms-backend@.service new file mode 100644 index 0000000..31a3446 --- /dev/null +++ b/packaging/systemd/yms-backend@.service @@ -0,0 +1,20 @@ +[Unit] +Description=YMS Backend (%i) +After=network.target + +[Service] +Type=simple +User=root +Group=root +EnvironmentFile=-/home/yms/bin/env/yms.env +WorkingDirectory=/home/yms/lib +ExecStart=/usr/bin/java -Xms1024m -Xmx10240m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/yms/dump -XX:ErrorFile=/home/yms/log/hs_err_pid%%p.log -XX:+ExitOnOutOfMemoryError -jar /home/yms/lib/glory-soft-yms-%i.jar --server.port=%i +Restart=on-failure +RestartSec=60 +TimeoutStopSec=150min +StandardOutput=journal +StandardError=journal +SyslogIdentifier=yms-backend-%i + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/yms-daemon.service b/packaging/systemd/yms-daemon.service new file mode 100644 index 0000000..b9bedc9 --- /dev/null +++ b/packaging/systemd/yms-daemon.service @@ -0,0 +1,23 @@ +[Unit] +Description=YMS Update Daemon +After=network.target nginx.service + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/bin/yms-daemon serve +Restart=on-failure +RestartSec=5 +RuntimeDirectory=yms-daemon +RuntimeDirectoryMode=0755 +StateDirectory=yms-daemon +StateDirectoryMode=0750 +LogsDirectory=yms-daemon +LogsDirectoryMode=0750 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=yms-daemon + +[Install] +WantedBy=multi-user.target diff --git a/packaging/tmpfiles.d/yms-daemon.conf b/packaging/tmpfiles.d/yms-daemon.conf new file mode 100644 index 0000000..49554ce --- /dev/null +++ b/packaging/tmpfiles.d/yms-daemon.conf @@ -0,0 +1 @@ +d /home/yms/dump 0755 root root -