From 79f18fcdea70cc8e82008bc79214390a04d386f6 Mon Sep 17 00:00:00 2001 From: Zhan Ziyang Date: Mon, 17 Aug 2026 02:10:10 +0800 Subject: [PATCH] feat: refactor to ymsctl & yms-daemon --- .gitignore | 2 + CLI_NAMING_AND_OPERATIONS_PLAN.md | 140 ++++++++++++++++++++++ RUNBOOK_CONTAINER_BACKEND.md | 2 +- RUNBOOK_NATIVE_BACKEND.md | 15 +-- internal/backendexecutor/executor.go | 18 +++ internal/backendexecutor/executor_test.go | 5 + internal/backendupdate/container.go | 8 +- internal/backendupdate/container_test.go | 18 +-- internal/containerengine/engine.go | 1 + internal/containerengine/moby.go | 15 +++ internal/daemonapi/protocol.go | 1 + internal/daemonclient/client.go | 4 +- internal/daemonserver/server.go | 4 +- internal/daemonserver/server_test.go | 4 +- internal/transaction/model.go | 7 ++ internal/transaction/store.go | 50 ++++++++ internal/transaction/store_test.go | 20 ++++ main.go | 86 ++++++++++--- main_test.go | 18 +++ packaging/rpm/build-rpm.sh | 5 +- packaging/rpm/yms-daemon.spec | 9 +- packaging/systemd/yms-daemon.service | 2 +- sonar-project.properties | 7 ++ 23 files changed, 394 insertions(+), 47 deletions(-) create mode 100644 CLI_NAMING_AND_OPERATIONS_PLAN.md create mode 100644 sonar-project.properties diff --git a/.gitignore b/.gitignore index 959f5f0..9fac41d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ yms-daemon /dist/ /.build/ +/coverage.out +/.scannerwork/ diff --git a/CLI_NAMING_AND_OPERATIONS_PLAN.md b/CLI_NAMING_AND_OPERATIONS_PLAN.md new file mode 100644 index 0000000..8348e1c --- /dev/null +++ b/CLI_NAMING_AND_OPERATIONS_PLAN.md @@ -0,0 +1,140 @@ +# YMS 命令入口与操作查询方案 + +> 状态:方案确认,暂不进入代码实现 + +## 1. 命令职责 + +命令入口采用 daemon/client 分离,命名参考 `chronyd / chronyc`: + +| 命令 | 职责 | 是否常驻 | +|---|---|---:| +| `ymsd` | 更新助手服务端,监听 Unix Socket,执行事务和恢复 | 是 | +| `ymsctl` | 运维和交付 CLI,提交更新、查询状态、诊断和回滚 | 否 | +| `yms-gui` | 客户 PC 图形化中转助手,名称后续确定 | 否 | + +`ymsd` 不承担交互式更新命令;更新请求由 `ymsctl` 通过 Unix Socket 提交。POC 阶段不对外提供 `serve` 子命令,systemd 的 `ExecStart` 直接启动 `/usr/sbin/ymsd`。 + +## 2. systemd 与安装 + +systemd unit 名称继续固定为: + +```text +yms-daemon.service +``` + +unit 的启动目标为: + +```text +/usr/sbin/ymsd +``` + +POC 阶段不保留 `yms-daemon` 命令兼容入口,直接采用新命名。更新命令统一使用: + +```bash +ymsctl update ... +ymsctl restart ... +``` + +## 3. ymsctl 命令层级 + +### 3.1 更新与回滚 + +```bash +ymsctl update --service backend -f +ymsctl update --service backend --native-jar +ymsctl update --service backend --container-image +ymsctl restart --service backend +ymsctl rollback --service backend --transaction +``` + +`--native-jar` 与 `--container-image` 互斥。`rollback` 必须指定明确的事务 ID 或已提交版本身份,不允许根据目录排序、文件修改时间或镜像 tag 猜测回滚目标。 + +### 3.2 历史操作查询 + +```bash +ymsctl list +ymsctl list --limit 50 +ymsctl list --state FAILED +ymsctl list --service backend +ymsctl list --json +``` + +默认显示最近 20 次操作,按创建时间倒序。每条记录至少展示: + +- 创建时间和结束时间; +- transaction ID; +- service; +- operation; +- 输入类型和版本或镜像 digest; +- 来源(CLI、PC client、Jenkins、server API); +- 最终状态; +- 失败原因。 + +`--json` 输出稳定 JSON,供 Jenkins 和 PC client 使用。列表数据只读取 SQLite,不直接扫描 systemd、Docker 或 Nginx 生成历史记录。 + +### 3.3 当前状态与诊断 + +```bash +ymsctl status --service backend +ymsctl doctor --service backend +ymsctl reconcile --service backend +ymsctl reconcile --service backend --apply +``` + +- `status`:读取 SQLite 后校验当前 systemd、Docker、Nginx 和活动槽位状态; +- `doctor`:只读诊断,输出漂移项和建议动作; +- `reconcile`:只读生成修复计划; +- `reconcile --apply`:执行明确授权的修复动作,并创建可审计事务。 + +## 4. 状态漂移处理原则 + +SQLite 是事务事实来源,外部系统是待校验运行状态。发现不一致时默认拒绝更新,不自动覆盖现场。 + +只有以下类型允许自动修复: + +1. SQLite 记录的活动容器存在、身份匹配且健康,Nginx 仅指向错误槽位; +2. 非活动槽位容器存在、已停止且没有提交记录; +3. native 活动 JAR 链接与已提交事务明确记录的 release 不一致,且目标文件身份校验通过; +4. 已完成事务的临时文件可以按照事务凭据清理。 + +以下情况必须进入 `DRIFT_DETECTED`,等待人工确认: + +- SQLite 记录的活动容器已经不存在; +- 两个槽位同时运行且无法确定提交归属; +- SQLite 没有部署记录,但现场已经存在容器或运行中的 native 服务; +- Nginx 配置无法验证或存在非 daemon 管理的冲突; +- systemd unit、容器镜像 digest、JAR SHA-256 与事务记录不一致。 + +任何修复动作都必须先写入事务,再执行外部变更;修复失败时沿用现有回滚和恢复机制。 + +## 5. systemd 服务与 ymsd 的边界 + +`ymsd` 负责: + +- 持有进程锁; +- 打开 SQLite; +- 恢复未完成事务; +- 调用 native/container 执行器; +- 通过 Unix Socket 提供请求和进度响应; +- 写入 stdout 和本地日志文件。 + +`ymsctl` 负责: + +- 参数校验; +- 提交请求; +- 展示进度; +- 查询历史和当前状态; +- 发起显式诊断和修复。 + +`ymsctl` 不直接写 SQLite,不直接修改 Nginx,不直接调用 Docker 或 systemd。 + +## 6. 实施顺序 + +1. 增加 SQLite 最近操作查询接口; +2. 实现 `ymsctl list` 和 JSON 输出; +3. 拆分 `ymsd` 与 `ymsctl` 的编译入口; +4. 安装 `ymsd`、`ymsctl` 和兼容入口 `yms-daemon`; +5. 实现 `status` 只读一致性检查; +6. 实现 `doctor` 漂移报告; +7. 实现带事务审计的 `reconcile --apply`; +8. 最后确定 GUI 名称和 PC client 的调用协议。 diff --git a/RUNBOOK_CONTAINER_BACKEND.md b/RUNBOOK_CONTAINER_BACKEND.md index 701fa87..9f78bda 100644 --- a/RUNBOOK_CONTAINER_BACKEND.md +++ b/RUNBOOK_CONTAINER_BACKEND.md @@ -52,7 +52,7 @@ host Nginx 当前指向 8080 时,`backend-8080` 必须正在运行;当前指 测试命令: ```bash -yms-daemon update \ +ymsctl update \ --service backend \ --container-image harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1 ``` diff --git a/RUNBOOK_NATIVE_BACKEND.md b/RUNBOOK_NATIVE_BACKEND.md index f65f8b7..5e20390 100644 --- a/RUNBOOK_NATIVE_BACKEND.md +++ b/RUNBOOK_NATIVE_BACKEND.md @@ -38,7 +38,7 @@ RPM 不会自动启动或重启 `yms-daemon.service`,避免安装恰好发生 systemctl restart yms-daemon.service ``` -`/etc/yms-daemon/yms-daemon.toml` 使用 `noreplace` 语义,升级不会覆盖现场配置。RPM 会安装 `/usr/bin/yms-daemon`、两个 systemd unit、tmpfiles 配置,并创建 `/home/yms/dump`。 +`/etc/yms-daemon/yms-daemon.toml` 使用 `noreplace` 语义,升级不会覆盖现场配置。RPM 会安装 `/usr/bin/ymsd` 和 `/usr/bin/ymsctl`、两个 systemd unit、tmpfiles 配置,并创建 `/home/yms/dump`。 RPM 示例配置使用客户环境值: @@ -69,7 +69,8 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o yms-daemon . 把构建结果和 `packaging` 目录传到服务器后,以 root 执行: ```bash -install -m 0755 yms-daemon /usr/bin/yms-daemon +install -m 0755 ymsd /usr/bin/ymsd +install -m 0755 ymsctl /usr/bin/ymsctl 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 @@ -95,7 +96,7 @@ curl -fsS http://127.0.0.1:8081/yms/actuator/health 客户 repack ZIP 模式: ```bash -yms-daemon update --service backend -f /home/yms/tmp/<完整包名>.zip +ymsctl update --service backend -f /home/yms/tmp/<完整包名>.zip ``` ZIP 根目录必须包含: @@ -116,7 +117,7 @@ selectedType = native 开发环境不需要打 ZIP。Jenkins 或交付人员先把完整 JAR 复制到 `/home/yms/tmp`,再执行: ```bash -yms-daemon update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar +ymsctl update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar ``` `-f` 与 `--native-jar` 必须且只能提供一个。daemon 不根据文件扩展名选择更新模式。直传 JAR 会被完整校验并计算 SHA-256,然后保存到: @@ -147,7 +148,7 @@ transaction=<事务ID> state=COMMITTED Jenkins 只需要退出码、不需要过程和成功结果时,使用精确参数 `--quite`: ```bash -yms-daemon update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar --quite +ymsctl update --service backend --native-jar /home/yms/tmp/<完整JAR文件名>.jar --quite ``` `--quite` 不写正常过程和成功结果;失败信息仍写入 stderr,并返回非零退出码。 @@ -169,7 +170,7 @@ journalctl -u yms-daemon.service -f 修改 `/home/yms/bin/env/yms.env` 等 backend 启动配置后,执行: ```bash -yms-daemon restart --service backend +ymsctl restart --service backend ``` daemon 读取 `/home/yms/lib/glory-soft-yms.jar` 当前指向的精确 release,创建独立 restart 事务,将同一 JAR 绑定到非活动槽位,启动并完成 Actuator 健康检查后切流,等待 drain,再停止旧槽。当前 JAR 已经位于 release 目录时直接复用;兼容入口仍是普通文件时,先按内容身份导入 release 目录。restart 不重新上传 JAR,也不以历史 update 的内容幂等键阻止本次轮转。 @@ -177,7 +178,7 @@ daemon 读取 `/home/yms/lib/glory-soft-yms.jar` 当前指向的精确 release 静默执行: ```bash -yms-daemon restart --service backend --quite +ymsctl restart --service backend --quite ``` restart 进行中如果 daemon 重启,再次执行同一命令会恢复唯一未完成的 backend restart 事务。存在其他未完成 update 事务时,restart 拒绝创建新事务并返回该活动事务 ID。 diff --git a/internal/backendexecutor/executor.go b/internal/backendexecutor/executor.go index a46a4f2..fddb3cd 100644 --- a/internal/backendexecutor/executor.go +++ b/internal/backendexecutor/executor.go @@ -3,6 +3,7 @@ package backendexecutor import ( + "bufio" "context" "encoding/json" "errors" @@ -62,6 +63,8 @@ type Request struct { ConfigLocation string RestartPolicy containerengine.RestartPolicy HealthEndpoint string + StartLog bool + LogReporter func(string) } // Executor drives the persisted transaction up to SWITCHING after the new container is healthy. @@ -210,6 +213,21 @@ func (e *Executor) startAndCheck(ctx context.Context, transactionID string, requ if _, err := e.coordinator.ExecuteStep(ctx, transactionID, startIntent(request), startOperation); err != nil { return err } + if request.StartLog && request.LogReporter != nil { + logs, err := e.engine.ContainerLogs(ctx, request.ContainerName) + if err == nil { + scanner := bufio.NewScanner(logs) + for scanner.Scan() { + request.LogReporter(scanner.Text()) + } + _ = logs.Close() + if err := scanner.Err(); err != nil { + return fmt.Errorf("read container startup logs: %w", err) + } + } else { + request.LogReporter("unable to read container startup logs: " + err.Error()) + } + } healthOperation := &healthOperation{ engine: e.engine, checker: e.checker, diff --git a/internal/backendexecutor/executor_test.go b/internal/backendexecutor/executor_test.go index 6377a05..d9b7a88 100644 --- a/internal/backendexecutor/executor_test.go +++ b/internal/backendexecutor/executor_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "slices" + "strings" "sync" "testing" @@ -408,6 +409,10 @@ func (e *fakeEngine) StartContainer(_ context.Context, name string) error { return nil } +func (e *fakeEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} + func (e *fakeEngine) StopContainer(_ context.Context, name string) error { e.mu.Lock() defer e.mu.Unlock() diff --git a/internal/backendupdate/container.go b/internal/backendupdate/container.go index 2093e14..743f1f0 100644 --- a/internal/backendupdate/container.go +++ b/internal/backendupdate/container.go @@ -33,6 +33,7 @@ type persistedContainerRequest struct { PreviousPort int `json:"previousPort"` PreviousContainer string `json:"previousContainer"` TargetHealthEndpoint string `json:"targetHealthEndpoint"` + StartLog bool `json:"startLog"` GatewayBeforePath string `json:"gatewayBeforePath"` GatewayAfterPath string `json:"gatewayAfterPath"` GatewayReceiptPath string `json:"gatewayReceiptPath"` @@ -40,7 +41,7 @@ type persistedContainerRequest struct { // UpdateContainerImage pulls one development image, freezes its repository // digest, and updates the inactive Docker backend slot. -func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, report ProgressReporter) (transaction.Transaction, error) { +func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, startLog bool, report ProgressReporter) (transaction.Transaction, error) { if u.containerExecutor == nil || u.engine == nil { return transaction.Transaction{}, errors.New("container backend updater is not configured") } @@ -138,6 +139,7 @@ func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference strin TargetPort: targetPort, TargetContainer: targetSlot.ContainerName, PreviousPort: before.ActivePort, PreviousContainer: previousContainer, TargetHealthEndpoint: targetSlot.HealthEndpoint, + StartLog: startLog, GatewayBeforePath: beforePath, GatewayAfterPath: afterPath, GatewayReceiptPath: filepath.Join(transactionRoot, "gateway.applied"), } @@ -294,6 +296,10 @@ func (u *Updater) runContainerUpdate(ctx context.Context, record transaction.Tra ConfigLocation: deploymentconfig.ContainerConfigLocation, RestartPolicy: containerengine.RestartPolicy{Name: "no"}, HealthEndpoint: request.TargetHealthEndpoint, + StartLog: request.StartLog, + LogReporter: func(line string) { + reportProgress(report, Progress{TransactionID: record.ID, State: transaction.StateStarting, Message: "CONTAINER LOG " + line}) + }, } switch record.State { case transaction.StateCreated, transaction.StateValidating, transaction.StatePrepared, transaction.StateStarting: diff --git a/internal/backendupdate/container_test.go b/internal/backendupdate/container_test.go index d80f16e..a3140bf 100644 --- a/internal/backendupdate/container_test.go +++ b/internal/backendupdate/container_test.go @@ -31,7 +31,7 @@ func TestContainerUpdaterPullsSwitchesAndStopsPreviousSlot(t *testing.T) { }, }, 0) - record, err := updater.UpdateContainerImage(ctx, containerTestImage, nil) + record, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil) if err != nil { t.Fatalf("update container backend: %v", err) } @@ -61,7 +61,7 @@ func TestContainerUpdaterFirstInstallCreatesInactiveSlotBeforeSwitch(t *testing. return nil } - record, err := updater.UpdateContainerImage(ctx, containerTestImage, func(item Progress) { + record, err := updater.UpdateContainerImage(ctx, containerTestImage, true, func(item Progress) { progress = append(progress, item) }) if err != nil { @@ -99,7 +99,7 @@ func TestContainerUpdaterRejectsMissingActiveWithPresentInactive(t *testing.T) { }, }, 0) - _, err := updater.UpdateContainerImage(context.Background(), containerTestImage, nil) + _, err := updater.UpdateContainerImage(context.Background(), containerTestImage, true, nil) if err == nil || !strings.Contains(err.Error(), "active backend container backend-8080 is missing but inactive container backend-8081 is running") { t.Fatalf("unexpected missing-active result: %v", err) } @@ -116,7 +116,7 @@ func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T return nil } - rollingBack, err := updater.UpdateContainerImage(ctx, containerTestImage, nil) + rollingBack, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil) if err == nil || rollingBack.State != transaction.StateRollingBack { t.Fatalf("unexpected failed rollback result: record=%+v err=%v", rollingBack, err) } @@ -126,12 +126,12 @@ func TestContainerUpdaterRecoversFailedRollbackThenRetriesSameImage(t *testing.T } failGateway = false - rolledBack, err := updater.UpdateContainerImage(ctx, containerTestImage, nil) + rolledBack, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil) if err == nil || rolledBack.State != transaction.StateRolledBack { t.Fatalf("unexpected resumed rollback result: record=%+v err=%v", rolledBack, err) } - committed, err := updater.UpdateContainerImage(ctx, containerTestImage, nil) + committed, err := updater.UpdateContainerImage(ctx, containerTestImage, true, nil) if err != nil || committed.State != transaction.StateCommitted { t.Fatalf("retry same image after rollback: record=%+v err=%v", committed, err) } @@ -169,7 +169,7 @@ func TestContainerUpdaterRejectsMissingCommittedContainer(t *testing.T) { t.Fatalf("commit previous backend deployment: %v", err) } - _, err = updater.UpdateContainerImage(ctx, containerTestImage, nil) + _, err = updater.UpdateContainerImage(ctx, containerTestImage, true, nil) if err == nil || !strings.Contains(err.Error(), "committed active backend container backend-8080 is missing") { t.Fatalf("unexpected committed-container drift result: %v", err) } @@ -336,6 +336,10 @@ func (e *containerUpdateEngine) StartContainer(_ context.Context, name string) e e.containers[name] = record return nil } + +func (e *containerUpdateEngine) ContainerLogs(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} func (e *containerUpdateEngine) StopContainer(_ context.Context, name string) error { e.stopped = append(e.stopped, name) record, found := e.containers[name] diff --git a/internal/containerengine/engine.go b/internal/containerengine/engine.go index 3cb720d..3b35fbc 100644 --- a/internal/containerengine/engine.go +++ b/internal/containerengine/engine.go @@ -79,6 +79,7 @@ type Engine interface { InspectImage(context.Context, string) (Image, error) CreateContainer(context.Context, ContainerSpec) (Container, error) StartContainer(context.Context, string) error + ContainerLogs(context.Context, string) (io.ReadCloser, error) StopContainer(context.Context, string) error InspectContainer(context.Context, string) (Container, error) RemoveContainer(context.Context, string, bool) error diff --git a/internal/containerengine/moby.go b/internal/containerengine/moby.go index dd2866f..82f6c01 100644 --- a/internal/containerengine/moby.go +++ b/internal/containerengine/moby.go @@ -1,6 +1,7 @@ package containerengine import ( + "bytes" "context" "encoding/json" "errors" @@ -8,6 +9,7 @@ import ( "io" cerrdefs "github.com/containerd/errdefs" + "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/jsonstream" "github.com/moby/moby/api/types/mount" @@ -148,6 +150,19 @@ func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error return nil } +func (e *MobyEngine) ContainerLogs(ctx context.Context, idOrName string) (io.ReadCloser, error) { + stream, err := e.client.ContainerLogs(ctx, idOrName, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "all"}) + if err != nil { + return nil, engineError("read container logs", err) + } + defer stream.Close() + var output bytes.Buffer + if _, err := stdcopy.StdCopy(&output, &output, stream); err != nil { + return nil, fmt.Errorf("decode container logs: %w", err) + } + return io.NopCloser(bytes.NewReader(output.Bytes())), nil +} + func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string) error { if _, err := e.client.ContainerStop(ctx, idOrName, client.ContainerStopOptions{}); err != nil { return engineError("stop container", err) diff --git a/internal/daemonapi/protocol.go b/internal/daemonapi/protocol.go index fc9d207..d4228a1 100644 --- a/internal/daemonapi/protocol.go +++ b/internal/daemonapi/protocol.go @@ -17,6 +17,7 @@ type Request struct { InputType string `json:"inputType"` File string `json:"file"` ImageReference string `json:"imageReference"` + StartLog bool `json:"startLog"` } type Response struct { diff --git a/internal/daemonclient/client.go b/internal/daemonclient/client.go index 22fd2dd..decd510 100644 --- a/internal/daemonclient/client.go +++ b/internal/daemonclient/client.go @@ -20,14 +20,14 @@ func Update(ctx context.Context, socketPath string, service string, inputType st return submit(ctx, socketPath, request, progress) } -func UpdateContainerImage(ctx context.Context, socketPath string, service string, imageReference string, progress func(daemonapi.Response)) (daemonapi.Response, error) { +func UpdateContainerImage(ctx context.Context, socketPath string, service string, imageReference string, startLog bool, progress func(daemonapi.Response)) (daemonapi.Response, error) { if !filepath.IsAbs(socketPath) { return daemonapi.Response{}, errors.New("daemon socket path must be absolute") } if imageReference == "" { return daemonapi.Response{}, errors.New("container image reference is required") } - request := daemonapi.Request{Operation: daemonapi.OperationUpdate, Service: service, InputType: daemonapi.InputTypeContainerImage, ImageReference: imageReference} + request := daemonapi.Request{Operation: daemonapi.OperationUpdate, Service: service, InputType: daemonapi.InputTypeContainerImage, ImageReference: imageReference, StartLog: startLog} return submit(ctx, socketPath, request, progress) } diff --git a/internal/daemonserver/server.go b/internal/daemonserver/server.go index 1b1c8e6..7c04d24 100644 --- a/internal/daemonserver/server.go +++ b/internal/daemonserver/server.go @@ -25,7 +25,7 @@ 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) - UpdateContainerImage(context.Context, string, backendupdate.ProgressReporter) (transaction.Transaction, error) + UpdateContainerImage(context.Context, string, bool, backendupdate.ProgressReporter) (transaction.Transaction, error) Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error) } @@ -141,7 +141,7 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) { _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "container-image requires imageReference and does not accept file"}) return } - record, updateErr = s.updater.UpdateContainerImage(ctx, request.ImageReference, report) + record, updateErr = s.updater.UpdateContainerImage(ctx, request.ImageReference, request.StartLog, report) default: _ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "inputType must be repack-zip, native-jar, or container-image"}) return diff --git a/internal/daemonserver/server_test.go b/internal/daemonserver/server_test.go index b3ce3da..11f3f48 100644 --- a/internal/daemonserver/server_test.go +++ b/internal/daemonserver/server_test.go @@ -98,7 +98,7 @@ func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) { waitForSocket(t, socketPath, serveResult) imageReference := "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1" - response, err := daemonclient.UpdateContainerImage(context.Background(), socketPath, "backend", imageReference, nil) + response, err := daemonclient.UpdateContainerImage(context.Background(), socketPath, "backend", imageReference, true, nil) if err != nil { t.Fatalf("submit container backend image: %v", err) } @@ -217,7 +217,7 @@ func (u *fakeUpdater) UpdateNativeJAR(_ context.Context, file string, report bac return u.record, u.err } -func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference string, report backendupdate.ProgressReporter) (transaction.Transaction, error) { +func (u *fakeUpdater) UpdateContainerImage(_ context.Context, imageReference string, _ bool, report backendupdate.ProgressReporter) (transaction.Transaction, error) { u.file = imageReference u.inputType = daemonapi.InputTypeContainerImage u.operation = daemonapi.OperationUpdate diff --git a/internal/transaction/model.go b/internal/transaction/model.go index d5783e5..1a8c14a 100644 --- a/internal/transaction/model.go +++ b/internal/transaction/model.go @@ -18,6 +18,13 @@ type Transaction struct { UpdatedAt time.Time } +// ListFilter limits the history query used by the ymsctl list command. +type ListFilter struct { + Limit int + Service string + State State +} + // CreateRequest 包含创建事务所需的不可变请求信息。 type CreateRequest struct { ID string diff --git a/internal/transaction/store.go b/internal/transaction/store.go index 60b724d..766b153 100644 --- a/internal/transaction/store.go +++ b/internal/transaction/store.go @@ -335,6 +335,56 @@ func (s *Store) ActiveTransaction(ctx context.Context) (Transaction, error) { return getActiveTransaction(ctx, s.db) } +// ListRecent returns committed, rolled-back, failed, and in-progress transactions +// in reverse creation order. Filters are exact values and never inferred. +func (s *Store) ListRecent(ctx context.Context, filter ListFilter) ([]Transaction, error) { + if filter.Limit <= 0 || filter.Limit > 1000 { + return nil, errors.New("transaction history limit must be between 1 and 1000") + } + query := `SELECT id, idempotency_key, source, service, request_json, state, version, created_at, updated_at FROM transactions WHERE 1=1` + args := make([]any, 0, 3) + if filter.Service != "" { + query += " AND service = ?" + args = append(args, filter.Service) + } + if filter.State != "" { + query += " AND state = ?" + args = append(args, string(filter.State)) + } + query += " ORDER BY created_at DESC, id DESC LIMIT ?" + args = append(args, filter.Limit) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list transaction history: %w", err) + } + defer rows.Close() + result := make([]Transaction, 0, filter.Limit) + for rows.Next() { + var item Transaction + var state string + var requestJSON string + var createdAt, updatedAt string + if err := rows.Scan(&item.ID, &item.IdempotencyKey, &item.Source, &item.Service, &requestJSON, &state, &item.Version, &createdAt, &updatedAt); err != nil { + return nil, fmt.Errorf("scan transaction history: %w", err) + } + item.Request = json.RawMessage(requestJSON) + item.State = State(state) + item.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) + if err != nil { + return nil, fmt.Errorf("parse transaction history creation time: %w", err) + } + item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return nil, fmt.Errorf("parse transaction history update time: %w", err) + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate transaction history: %w", err) + } + return result, nil +} + // Transition 校验并原子提交状态变化及其恢复事件。 func (s *Store) Transition(ctx context.Context, id string, next State, message string) (Transaction, error) { if !next.Valid() { diff --git a/internal/transaction/store_test.go b/internal/transaction/store_test.go index 9c67aa9..b1c2092 100644 --- a/internal/transaction/store_test.go +++ b/internal/transaction/store_test.go @@ -105,6 +105,26 @@ func TestStoreMigratesVersionOneAndCommitsBackendContainerDeployment(t *testing. } } +func TestListRecentScansSQLiteRequestTextAsRawJSON(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + record, _, err := store.CreateTransaction(ctx, CreateRequest{ + ID: "list-transaction", IdempotencyKey: "list-idempotency", Source: "test", Service: "backend", + Request: json.RawMessage(`{"inputType":"native-jar"}`), + }) + if err != nil { + t.Fatalf("create list transaction: %v", err) + } + items, err := store.ListRecent(ctx, ListFilter{Limit: 20}) + if err != nil { + t.Fatalf("list transactions: %v", err) + } + if len(items) != 1 || items[0].ID != record.ID || string(items[0].Request) != `{"inputType":"native-jar"}` { + t.Fatalf("unexpected listed transaction: %+v", items) + } +} + func TestBackendContainerDeploymentCommitRequiresDrainingTransaction(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/main.go b/main.go index 6e9d7f8..25e1580 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -33,6 +34,17 @@ const serviceBackend = "backend" func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + if filepath.Base(os.Args[0]) == "ymsd" { + if len(os.Args) != 1 { + fmt.Fprintln(os.Stderr, "ymsd does not accept arguments") + os.Exit(2) + } + if err := runServe(ctx); err != nil { + fmt.Fprintln(os.Stderr, "ymsd failed:", err) + os.Exit(1) + } + return + } os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr)) } @@ -42,16 +54,6 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr 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 { @@ -66,7 +68,7 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr } var response daemonapi.Response if request.inputType == daemonapi.InputTypeContainerImage { - response, err = daemonclient.UpdateContainerImage(ctx, runtimepaths.Socket, request.service, request.imageReference, progress) + response, err = daemonclient.UpdateContainerImage(ctx, runtimepaths.Socket, request.service, request.imageReference, request.startLog, progress) } else { response, err = daemonclient.Update(ctx, runtimepaths.Socket, request.service, request.inputType, request.file, progress) } @@ -74,7 +76,7 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr 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) + fmt.Fprintln(stderr, "ymsctl update failed:", err) } return 1 } @@ -82,6 +84,12 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State) } return 0 + case "list": + if err := runList(ctx, arguments[1:], stdout, stderr); err != nil { + fmt.Fprintln(stderr, "ymsctl list failed:", err) + return 1 + } + return 0 case "restart": request, err := parseRestartArgs(arguments[1:], stderr) if err != nil { @@ -99,7 +107,7 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr 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) + fmt.Fprintln(stderr, "ymsctl restart failed:", err) } return 1 } @@ -123,6 +131,7 @@ type updateArguments struct { file string imageReference string quite bool + startLog bool } type restartArguments struct { @@ -138,6 +147,7 @@ func parseUpdateArgs(arguments []string, output io.Writer) (updateArguments, err nativeJAR := flags.String("native-jar", "", "direct native backend JAR path") containerImage := flags.String("container-image", "", "development backend container image reference") quite := flags.Bool("quite", false, "suppress progress and successful result output") + noStartLog := flags.Bool("no-start-log", false, "do not print container startup logs") if err := flags.Parse(arguments); err != nil { return updateArguments{}, err } @@ -157,7 +167,10 @@ func parseUpdateArgs(arguments []string, output io.Writer) (updateArguments, err return updateArguments{}, errors.New("exactly one of -f, --native-jar, and --container-image is required; update inputs are mutually exclusive") } if *containerImage != "" { - return updateArguments{service: *service, inputType: daemonapi.InputTypeContainerImage, imageReference: *containerImage, quite: *quite}, nil + return updateArguments{service: *service, inputType: daemonapi.InputTypeContainerImage, imageReference: *containerImage, quite: *quite, startLog: !*noStartLog}, nil + } + if *noStartLog { + return updateArguments{}, errors.New("--no-start-log requires --container-image") } inputType := daemonapi.InputTypeRepackZIP inputFile := *file @@ -266,11 +279,11 @@ func runServe(ctx context.Context) (result error) { 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 update --service backend --container-image [--quite]") - fmt.Fprintln(output, " yms-daemon restart --service backend [--quite]") + fmt.Fprintln(output, " ymsctl update --service backend -f [--quite]") + fmt.Fprintln(output, " ymsctl update --service backend --native-jar [--quite]") + fmt.Fprintln(output, " ymsctl update --service backend --container-image [--no-start-log] [--quite]") + fmt.Fprintln(output, " ymsctl restart --service backend [--quite]") + fmt.Fprintln(output, " ymsctl list [--limit ] [--service backend] [--state ] [--json]") } func writeUpdateProgress(output io.Writer, event daemonapi.Response) { @@ -280,3 +293,38 @@ func writeUpdateProgress(output io.Writer, event daemonapi.Response) { } fmt.Fprintf(output, "%-14s %s\n", state, event.Message) } + +func runList(ctx context.Context, arguments []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("list", flag.ContinueOnError) + flags.SetOutput(stderr) + limit := flags.Int("limit", 20, "maximum number of operations to show") + service := flags.String("service", "", "exact service filter") + state := flags.String("state", "", "exact transaction state filter") + jsonOutput := flags.Bool("json", false, "write stable JSON") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("list does not accept positional arguments") + } + if *service != "" && *service != serviceBackend { + return errors.New("--service currently accepts only backend") + } + store, err := transaction.OpenStore(ctx, runtimepaths.Database) + if err != nil { + return err + } + defer store.Close() + items, err := store.ListRecent(ctx, transaction.ListFilter{Limit: *limit, Service: *service, State: transaction.State(*state)}) + if err != nil { + return err + } + if *jsonOutput { + return json.NewEncoder(stdout).Encode(items) + } + fmt.Fprintln(stdout, "TIME\tTRANSACTION\tSERVICE\tSTATE\tSOURCE") + for _, item := range items { + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%s\n", item.CreatedAt.Local().Format("2006-01-02 15:04:05"), item.ID, item.Service, item.State, item.Source) + } + return nil +} diff --git a/main_test.go b/main_test.go index c651697..ca8211d 100644 --- a/main_test.go +++ b/main_test.go @@ -50,6 +50,24 @@ func TestParseUpdateArgsAcceptsContainerImage(t *testing.T) { } } +func TestParseUpdateArgsControlsContainerStartupLogs(t *testing.T) { + imageReference := "harbor.ymswell.asia/ymswell/glory-ymswell:20260813-184902-a37bf50d-v1.1.8.1" + request, err := parseUpdateArgs([]string{"--service", "backend", "--container-image", imageReference, "--no-start-log"}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse no-start-log container update arguments: %v", err) + } + if request.startLog { + t.Fatal("--no-start-log did not disable startup logs") + } + request, err = parseUpdateArgs([]string{"--service", "backend", "--container-image", imageReference}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parse default container update arguments: %v", err) + } + if !request.startLog { + t.Fatal("container startup logs are not enabled by default") + } +} + func TestParseUpdateArgsRejectsNativeJARAndContainerImageTogether(t *testing.T) { jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar") arguments := []string{ diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index 5a7d221..d4ba90d 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -71,12 +71,13 @@ sources_dir="$top_dir/SOURCES" temporary_dir="$build_dir/tmp" mkdir -p "$sources_dir" "$top_dir/BUILD" "$top_dir/BUILDROOT" "$top_dir/RPMS" "$top_dir/SRPMS" "$temporary_dir" -echo "Building static linux/$go_arch yms-daemon" +echo "Building static linux/$go_arch ymsd and ymsctl" ( cd "$project_dir" GOCACHE="$build_dir/go-cache" CGO_ENABLED=0 GOOS=linux GOARCH="$go_arch" \ - go build -trimpath -ldflags="-s -w" -o "$sources_dir/yms-daemon" . + go build -trimpath -ldflags="-s -w" -o "$sources_dir/ymsd" . ) +cp "$sources_dir/ymsd" "$sources_dir/ymsctl" install -m 0640 "$project_dir/packaging/etc/yms-daemon/yms-daemon.toml" "$sources_dir/yms-daemon.toml" install -m 0644 "$project_dir/packaging/systemd/yms-daemon.service" "$sources_dir/yms-daemon.service" diff --git a/packaging/rpm/yms-daemon.spec b/packaging/rpm/yms-daemon.spec index d169f96..e814887 100644 --- a/packaging/rpm/yms-daemon.spec +++ b/packaging/rpm/yms-daemon.spec @@ -12,11 +12,12 @@ Summary: YMS update daemon License: Proprietary Requires: systemd -Source0: yms-daemon +Source0: ymsd Source1: yms-daemon.toml Source2: yms-daemon.service Source3: yms-backend@.service Source4: yms-daemon-tmpfiles.conf +Source5: ymsctl %description Transactional update daemon and native backend systemd units for YMS installations. @@ -27,7 +28,8 @@ Transactional update daemon and native backend systemd units for YMS installatio %install install -d -m 0755 %{buildroot}/usr/bin -install -m 0755 %{SOURCE0} %{buildroot}/usr/bin/yms-daemon +install -m 0755 %{SOURCE0} %{buildroot}/usr/bin/ymsd +install -m 0755 %{SOURCE5} %{buildroot}/usr/bin/ymsctl install -d -m 0755 %{buildroot}/etc/yms-daemon install -m 0640 %{SOURCE1} %{buildroot}/etc/yms-daemon/yms-daemon.toml @@ -71,7 +73,8 @@ fi %attr(0644,root,root) /etc/systemd/system/yms-daemon.service %attr(0644,root,root) /etc/systemd/system/yms-backend@.service %attr(0644,root,root) /etc/tmpfiles.d/yms-daemon.conf -%attr(0755,root,root) /usr/bin/yms-daemon +%attr(0755,root,root) /usr/bin/ymsd +%attr(0755,root,root) /usr/bin/ymsctl %dir %attr(0755,root,root) /home/yms/dump %changelog diff --git a/packaging/systemd/yms-daemon.service b/packaging/systemd/yms-daemon.service index b9bedc9..40acdbf 100644 --- a/packaging/systemd/yms-daemon.service +++ b/packaging/systemd/yms-daemon.service @@ -6,7 +6,7 @@ After=network.target nginx.service Type=simple User=root Group=root -ExecStart=/usr/bin/yms-daemon serve +ExecStart=/usr/bin/ymsd Restart=on-failure RestartSec=5 RuntimeDirectory=yms-daemon diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..cbb0497 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey=yms-daemon +sonar.projectName=yms-daemon +sonar.sources=. +sonar.go.coverage.reportPaths=coverage.out +sonar.host.url=http://10.11.1.150:9000 +sonar.login=sqa_01cc7f5c4cc3823ed99abc95a36d3657cec0391e +sonar.branch.name=master \ No newline at end of file