feat: complete daemon operations and recovery tooling

This commit is contained in:
2026-08-22 15:34:09 +08:00
parent a0a4a7556a
commit 1abe8df6ee
19 changed files with 1421 additions and 171 deletions
+33
View File
@@ -0,0 +1,33 @@
# Changelog
yms-daemon 面向用户的版本历史,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格与语义化版本(SemVer)。
当前版本:**0.1.0**POC 阶段,尚未达到 RC/GA)。
版本号策略:POC 阶段沿用 `0.1.x`;功能达到 RC 时发布 `0.1.0-rc.N`(或按规模升 `0.2.0-rc.N`);达到 GA 时发布 `1.0.0`,并把对应条目从 `[Unreleased]` 移入正式版本号。
## [Unreleased]
### Added
- 命令入口拆分:`ymsd`(守护进程,监听 Unix Socket/ `ymsctl`(运维 CLI),chrony 风格分离。
- backend native 更新:repack ZIP`-f`)与直传 JAR`--native-jar`)两种互斥输入。
- backend container 更新:`--container-image`pull 后冻结 repository digest,蓝绿切流,含全新机器首次部署)。
- 同版本零停机重启:`ymsctl restart --service backend`native + container)。
- 历史查询:`ymsctl list [--limit/--service/--state/--json]`
- 诊断与对账:`ymsctl status` / `doctor` / `reconcile [--apply]`
- 宿主 Nginx 蓝绿切流:managed upstream 块 + `nginx -s reload`
- 服务端 SQLite 事务、步骤幂等与 backend container 部署记录。
- 启动日志跟随:update / restart 期间实时输出容器启动日志,直到 Actuator 健康。
### Changed
- 更新命令由旧 `yms-daemon update` 迁移为 `ymsctl update`
- `hostnginx.NewController` 简化为两参数,重载改走 `nginx -s reload`
- 容器停止旧容器改为显式超时(默认 30 秒后强制终止),可通过 `backend.stop_grace_seconds` 配置,覆盖原先 9000 秒 StopTimeout。
- 示例配置 `yms-daemon.toml` 同时给出 native / container 两套 `[backend]` 块与切换说明。
### Known limitations
- native 后端的 `reconcile --apply` 尚无自动修复项(JAR 链接漂移修复待实现)。
- `rollback``frontend`/`nodeSsr`/`all` 更新入口、离线 repack 交付尚未实现。
-140
View File
@@ -1,140 +0,0 @@
# 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 <repack.zip>
ymsctl update --service backend --native-jar <backend.jar>
ymsctl update --service backend --container-image <image:tag>
ymsctl restart --service backend
ymsctl rollback --service backend --transaction <transaction-id>
```
`--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 的调用协议。
+144
View File
@@ -1790,3 +1790,147 @@ Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责:
37. 未提交文件的隔离目录、识别规则、保留期限和清理流程。
上述精确项冻结前,不修改现网更新脚本、systemd、Nginx 配置和发布包格式。
---
# 附录:YMS 命令入口与操作查询方案
> 状态:方案确认。ymsd/ymsctl 拆分、list、status、doctor、reconcile 已实现;restart 的 container 后端已实现;rollback 待实现。
## 1. 命令职责
命令入口采用 daemon/client 分离,命名参考 `chronyd / chronyc`
| 命令 | 职责 | 是否常驻 |
|---|---|---:|
| `ymsd` | 更新助手服务端,监听 Unix Socket,执行事务和恢复 | 是 |
| `ymsctl` | 运维和交付 CLI,提交更新、查询状态、诊断和回滚 | 否 |
| `yms-gui` | 客户 PC 图形化中转助手,名称后续确定 | 否 |
`ymsd` 不承担交互式更新命令;更新请求由 `ymsctl` 通过 Unix Socket 提交。POC 阶段不对外提供 `serve` 子命令,systemd 的 `ExecStart` 直接启动 `/usr/bin/ymsd`
## 2. systemd 与安装
systemd unit 名称继续固定为:
```text
yms-daemon.service
```
unit 的启动目标为:
```text
/usr/bin/ymsd
```
POC 阶段不保留 `yms-daemon` 命令兼容入口,直接采用新命名。更新命令统一使用:
```bash
ymsctl update ...
ymsctl restart ...
```
## 3. ymsctl 命令层级
### 3.1 更新与回滚
```bash
ymsctl update --service backend -f <repack.zip>
ymsctl update --service backend --native-jar <backend.jar>
ymsctl update --service backend --container-image <image:tag>
ymsctl restart --service backend
ymsctl rollback --service backend --transaction <transaction-id>
```
`--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 的调用协议。
+1 -1
View File
@@ -451,7 +451,7 @@ func (e *fakeEngine) ContainerLogs(context.Context, string) (io.ReadCloser, erro
}
// StopContainer 实现容器引擎的 StopContainer,将指定容器标记为已退出状态,不存在时返回 ErrNotFound。
func (e *fakeEngine) StopContainer(_ context.Context, name string) error {
func (e *fakeEngine) StopContainer(_ context.Context, name string, _ int) error {
e.mu.Lock()
defer e.mu.Unlock()
record, exists := e.containers[name]
+203
View File
@@ -0,0 +1,203 @@
package backendstatus
import (
"context"
"errors"
"fmt"
"yms-daemon/internal/containerengine"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/transaction"
)
// 诊断项稳定标识,供测试与 reconcile 识别,不参与展示。
const (
codeUndeployed = "container_backend_undeployed"
codeUndeployedWithHistory = "container_backend_undeployed_with_history"
codeContainerWithoutRecord = "container_present_without_deployment_record"
codeCommittedPortOK = "nginx_active_port"
codeNginxWrongPort = "nginx_wrong_port"
codeActiveContainerOK = "active_container_healthy"
codeActiveContainerMissing = "active_container_missing"
codeActiveContainerIdentity = "active_container_identity_mismatch"
codeActiveContainerNotRunning = "active_container_not_running"
codeInactiveContainerOK = "inactive_container_absent"
codeInactiveContainerResidual = "inactive_container_residual"
codeInactiveContainerRunning = "inactive_container_running"
codeDeploymentConfigMismatch = "deployment_record_config_mismatch"
)
// diagnoseContainer 对 container 后端做只读诊断。
// 事实来源是 backend_container_deployment 单例记录;现场状态由容器引擎与宿主 Nginx 提供。
func (d *Diagnoser) diagnoseContainer(ctx context.Context) (daemonapi.Diagnosis, error) {
findings, err := d.containerGather(ctx)
if err != nil {
return daemonapi.Diagnosis{}, err
}
return toDiagnosis("backend", deploymentconfig.BackendTypeContainer, findings), nil
}
// containerGather 读取 container 后端的部署记录、历史事务与宿主 Nginx 状态,生成全部诊断项。
func (d *Diagnoser) containerGather(ctx context.Context) ([]finding, error) {
deployment, depErr := d.store.BackendContainerDeployment(ctx)
hasDeployment := depErr == nil
if depErr != nil && !errors.Is(depErr, transaction.ErrNotFound) {
return nil, fmt.Errorf("read backend container deployment: %w", depErr)
}
hasHistory, err := d.store.HasCommittedBackendContainerTransactionHistory(ctx)
if err != nil {
return nil, err
}
before, err := d.gateway.Read()
if err != nil {
return nil, fmt.Errorf("read host Nginx configuration: %w", err)
}
return d.containerFindings(ctx, deployment, hasDeployment, hasHistory, before.ActivePort)
}
// containerFindings 生成 container 后端的全部诊断项。
// deployment 部署记录,hasDeployment 表示是否存在该记录,hasHistory 表示是否存在历史
// 已提交事务,activePort 是宿主 Nginx 当前指向的后端端口。
func (d *Diagnoser) containerFindings(ctx context.Context, deployment transaction.BackendContainerDeployment, hasDeployment bool, hasHistory bool, activePort int) ([]finding, error) {
if !hasDeployment {
return d.containerUndeployedFindings(ctx, hasHistory)
}
findings := make([]finding, 0, 6)
committedSlot, err := d.config.Backend.SlotForPort(deployment.ActivePort)
if err != nil {
return nil, err
}
if deployment.ContainerName != committedSlot.ContainerName {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeDeploymentConfigMismatch,
message: fmt.Sprintf("部署记录容器 %s 与活动端口 %d 的槽位容器 %s 不一致", deployment.ContainerName, deployment.ActivePort, committedSlot.ContainerName),
})
return findings, nil
}
committedContainer, committedErr := d.engine.InspectContainer(ctx, deployment.ContainerName)
committedHealthy := false
switch {
case errors.Is(committedErr, containerengine.ErrNotFound):
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeActiveContainerMissing,
message: "部署记录的活动容器 " + deployment.ContainerName + " 不存在",
})
case committedErr != nil:
return nil, fmt.Errorf("inspect committed backend container %s: %w", deployment.ContainerName, committedErr)
case committedContainer.ID != deployment.ContainerID:
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeActiveContainerIdentity,
message: "活动容器 " + deployment.ContainerName + " 身份与部署记录不一致",
})
case !committedContainer.Running || committedContainer.Dead:
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeActiveContainerNotRunning,
message: "活动容器 " + deployment.ContainerName + " 未运行",
})
default:
committedHealthy = true
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeActiveContainerOK,
message: "活动容器 " + deployment.ContainerName + " 健康",
})
}
if activePort == deployment.ActivePort {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeCommittedPortOK,
message: fmt.Sprintf("宿主 Nginx 指向部署记录的活动端口 %d", deployment.ActivePort),
})
} else if committedHealthy {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelFixable,
code: codeNginxWrongPort,
message: fmt.Sprintf("宿主 Nginx 指向 %d,但部署记录的活动端口是 %d", activePort, deployment.ActivePort),
action: fmt.Sprintf("将宿主 Nginx 切流到端口 %d", deployment.ActivePort),
fix: &fixAction{kind: fixSwitchPort, port: deployment.ActivePort},
})
} else {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeNginxWrongPort,
message: fmt.Sprintf("宿主 Nginx 指向 %d,与部署记录活动端口 %d 不一致,且活动容器不健康", activePort, deployment.ActivePort),
})
}
inactiveSlot, err := d.config.Backend.SlotForPort(otherPort(deployment.ActivePort))
if err != nil {
return nil, err
}
inactiveContainer, inactiveErr := d.engine.InspectContainer(ctx, inactiveSlot.ContainerName)
switch {
case errors.Is(inactiveErr, containerengine.ErrNotFound):
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeInactiveContainerOK,
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 不存在",
})
case inactiveErr != nil:
return nil, fmt.Errorf("inspect inactive backend container %s: %w", inactiveSlot.ContainerName, inactiveErr)
case inactiveContainer.Running && !inactiveContainer.Dead:
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeInactiveContainerRunning,
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 意外处于运行状态",
})
default:
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelFixable,
code: codeInactiveContainerResidual,
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 已停止,属于残留",
action: "移除残留的已停止容器 " + inactiveSlot.ContainerName,
fix: &fixAction{kind: fixRemoveContainer, container: inactiveSlot.ContainerName},
})
}
return findings, nil
}
// containerUndeployedFindings 处理不存在部署记录的情况,依据容器现场与历史记录判断是否为全新机器。
func (d *Diagnoser) containerUndeployedFindings(ctx context.Context, hasHistory bool) ([]finding, error) {
var present []string
for _, port := range []int{deploymentconfig.BackendPort8080, deploymentconfig.BackendPort8081} {
slot, err := d.config.Backend.SlotForPort(port)
if err != nil {
return nil, err
}
if _, err := d.engine.InspectContainer(ctx, slot.ContainerName); err == nil {
present = append(present, slot.ContainerName)
} else if !errors.Is(err, containerengine.ErrNotFound) {
return nil, fmt.Errorf("inspect backend container %s: %w", slot.ContainerName, err)
}
}
if len(present) > 0 {
return []finding{{
level: daemonapi.DiagnosisLevelDrift,
code: codeContainerWithoutRecord,
message: fmt.Sprintf("SQLite 没有部署记录,但现场存在容器 %v", present),
}}, nil
}
if hasHistory {
return []finding{{
level: daemonapi.DiagnosisLevelDrift,
code: codeUndeployedWithHistory,
message: "存在历史已提交事务,但缺少部署记录且两个槽位容器均不存在",
}}, nil
}
return []finding{{
level: daemonapi.DiagnosisLevelOK,
code: codeUndeployed,
message: "全新机器,尚未部署 backend 容器",
}}, nil
}
+291
View File
@@ -0,0 +1,291 @@
package backendstatus
import (
"context"
"crypto/rand"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"yms-daemon/internal/containerengine"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/transaction"
)
// TestContainerDiagnoseUndeployed 验证全新机器(无部署记录、无容器、无历史事务)被诊断为健康且未部署。
func TestContainerDiagnoseUndeployed(t *testing.T) {
d, _ := newContainerDiagnoser(t, &fakeEngine{}, &fakeGateway{port: 8080})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose undeployed container backend: %v", err)
}
if !diagnosis.Healthy {
t.Fatalf("undeployed machine should be healthy: %+v", diagnosis)
}
if diagnosis.Items[0].Level != daemonapi.DiagnosisLevelOK || diagnosis.Items[0].Code != codeUndeployed {
t.Fatalf("unexpected undeployed finding: %+v", diagnosis.Items)
}
}
// TestContainerDiagnoseHealthy 验证部署记录、活动容器与 Nginx 三者一致时被诊断为健康。
func TestContainerDiagnoseHealthy(t *testing.T) {
engine := &fakeEngine{containers: map[string]containerengine.Container{
"backend-8080": {ID: "container-8080", Name: "backend-8080", Running: true},
}}
d, store := newContainerDiagnoser(t, engine, &fakeGateway{port: 8080})
commitDeployment(t, store, transaction.BackendContainerDeployment{
ActivePort: 8080, ContainerName: "backend-8080", ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ContainerID: "container-8080",
})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose healthy container backend: %v", err)
}
if !diagnosis.Healthy {
t.Fatalf("healthy container backend reported drift: %+v", diagnosis)
}
for _, item := range diagnosis.Items {
if item.Level == daemonapi.DiagnosisLevelDrift || item.Level == daemonapi.DiagnosisLevelFixable {
t.Fatalf("unexpected non-ok finding: %+v", item)
}
}
}
// TestContainerDiagnoseNginxWrongPort 验证 Nginx 指向错误端口而活动容器健康时判定为可自动修复。
func TestContainerDiagnoseNginxWrongPort(t *testing.T) {
engine := &fakeEngine{containers: map[string]containerengine.Container{
"backend-8080": {ID: "container-8080", Name: "backend-8080", Running: true},
}}
d, store := newContainerDiagnoser(t, engine, &fakeGateway{port: 8081})
commitDeployment(t, store, transaction.BackendContainerDeployment{
ActivePort: 8080, ContainerName: "backend-8080", ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ContainerID: "container-8080",
})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose nginx wrong port: %v", err)
}
if !diagnosis.Healthy {
t.Fatalf("nginx wrong port with healthy container should be fixable: %+v", diagnosis)
}
if !hasFinding(diagnosis, daemonapi.DiagnosisLevelFixable, codeNginxWrongPort) {
t.Fatalf("missing nginx wrong port finding: %+v", diagnosis.Items)
}
}
// TestContainerDiagnoseActiveContainerMissing 验证部署记录的活动容器缺失时判定为漂移。
func TestContainerDiagnoseActiveContainerMissing(t *testing.T) {
d, store := newContainerDiagnoser(t, &fakeEngine{}, &fakeGateway{port: 8080})
commitDeployment(t, store, transaction.BackendContainerDeployment{
ActivePort: 8080, ContainerName: "backend-8080", ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ContainerID: "container-8080",
})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose missing active container: %v", err)
}
if diagnosis.Healthy {
t.Fatalf("missing active container should be drift: %+v", diagnosis)
}
if !hasFinding(diagnosis, daemonapi.DiagnosisLevelDrift, codeActiveContainerMissing) {
t.Fatalf("missing active container finding: %+v", diagnosis.Items)
}
}
// TestContainerDiagnoseInactiveResidual 验证非活动槽位残留已停止容器时判定为可自动修复。
func TestContainerDiagnoseInactiveResidual(t *testing.T) {
engine := &fakeEngine{containers: map[string]containerengine.Container{
"backend-8080": {ID: "container-8080", Name: "backend-8080", Running: true},
"backend-8081": {ID: "container-8081", Name: "backend-8081", Running: false},
}}
d, store := newContainerDiagnoser(t, engine, &fakeGateway{port: 8080})
commitDeployment(t, store, transaction.BackendContainerDeployment{
ActivePort: 8080, ContainerName: "backend-8080", ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ContainerID: "container-8080",
})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose inactive residual: %v", err)
}
if !hasFinding(diagnosis, daemonapi.DiagnosisLevelFixable, codeInactiveContainerResidual) {
t.Fatalf("missing inactive residual finding: %+v", diagnosis.Items)
}
}
// TestContainerDiagnoseContainerWithoutRecord 验证无部署记录但现场存在容器时判定为漂移。
func TestContainerDiagnoseContainerWithoutRecord(t *testing.T) {
engine := &fakeEngine{containers: map[string]containerengine.Container{
"backend-8080": {ID: "container-8080", Name: "backend-8080", Running: true},
}}
d, _ := newContainerDiagnoser(t, engine, &fakeGateway{port: 8080})
diagnosis, err := d.Diagnose(context.Background())
if err != nil {
t.Fatalf("diagnose container without record: %v", err)
}
if diagnosis.Healthy {
t.Fatalf("container without record should be drift: %+v", diagnosis)
}
if !hasFinding(diagnosis, daemonapi.DiagnosisLevelDrift, codeContainerWithoutRecord) {
t.Fatalf("missing container-without-record finding: %+v", diagnosis.Items)
}
}
// TestContainerReconcileApplyFixesNginx 验证 reconcile --apply 能把指向错误的 Nginx 切回记录端口。
func TestContainerReconcileApplyFixesNginx(t *testing.T) {
engine := &fakeEngine{containers: map[string]containerengine.Container{
"backend-8080": {ID: "container-8080", Name: "backend-8080", Running: true},
}}
gateway := &fakeGateway{port: 8081}
d, store := newContainerDiagnoser(t, engine, gateway)
commitDeployment(t, store, transaction.BackendContainerDeployment{
ActivePort: 8080, ContainerName: "backend-8080", ImageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ContainerID: "container-8080",
})
diagnosis, err := d.Reconcile(context.Background(), true)
if err != nil {
t.Fatalf("reconcile apply nginx: %v", err)
}
if gateway.port != 8080 {
t.Fatalf("reconcile did not switch Nginx to port 8080: port=%d", gateway.port)
}
if !diagnosis.Healthy {
t.Fatalf("reconcile result should be healthy: %+v", diagnosis)
}
if hasFinding(diagnosis, daemonapi.DiagnosisLevelFixable, codeNginxWrongPort) {
t.Fatalf("nginx wrong port should be fixed after reconcile: %+v", diagnosis.Items)
}
}
// hasFinding 判断诊断结果中是否存在指定级别与稳定标识的诊断项。
func hasFinding(diagnosis daemonapi.Diagnosis, level string, code string) bool {
for _, item := range diagnosis.Items {
if item.Level == level && item.Code == code {
return true
}
}
return false
}
// newContainerDiagnoser 构造 container 后端的诊断器及其底层存储,返回诊断器与可写的存储。
func newContainerDiagnoser(t *testing.T, engine *fakeEngine, gateway *fakeGateway) (*Diagnoser, *transaction.Store) {
t.Helper()
ctx := context.Background()
store, err := transaction.OpenStore(ctx, 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)
}
config := deploymentconfig.Config{
Daemon: deploymentconfig.Daemon{Environment: deploymentconfig.EnvironmentDev},
Backend: deploymentconfig.Backend{
Type: deploymentconfig.BackendTypeContainer,
SystemctlPath: "/bin/systemctl",
Slot: deploymentconfig.BackendSlots{
Port8080: deploymentconfig.BackendSlot{ContainerName: "backend-8080", HealthEndpoint: "http://127.0.0.1:8080/yms/actuator/health"},
Port8081: deploymentconfig.BackendSlot{ContainerName: "backend-8081", HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health"},
},
},
}
diagnoser, err := New(config, store, coordinator, engine, nil, gateway)
if err != nil {
t.Fatalf("create diagnoser: %v", err)
}
return diagnoser, store
}
// commitDeployment 通过合法的状态机推进写入一条 container 部署记录。
func commitDeployment(t *testing.T, store *transaction.Store, deployment transaction.BackendContainerDeployment) {
t.Helper()
ctx := context.Background()
transactionID := "deploy-" + rand.Text()
if _, _, err := store.CreateTransaction(ctx, transaction.CreateRequest{
ID: transactionID,
IdempotencyKey: "backend:container:test:" + rand.Text(),
Source: "test",
Service: "backend",
}); err != nil {
t.Fatalf("create deployment transaction: %v", err)
}
for _, state := range []transaction.State{
transaction.StateValidating,
transaction.StatePrepared,
transaction.StateStarting,
transaction.StateSwitching,
transaction.StateVerifying,
transaction.StateDraining,
} {
if _, err := store.Transition(ctx, transactionID, state, "test"); err != nil {
t.Fatalf("advance deployment transaction: %v", err)
}
}
if _, err := store.CommitBackendContainerDeployment(ctx, transactionID, deployment, "test committed"); err != nil {
t.Fatalf("commit deployment: %v", err)
}
}
// fakeGateway gateway 接口的内存实现,记录活动端口并在 Switch 时直接更新。
type fakeGateway struct {
port int
content []byte
}
// Read 返回当前快照与活动端口。
func (g *fakeGateway) Read() (hostnginx.Snapshot, error) {
content := g.content
if content == nil {
content = []byte("http {\n upstream yms-server {\n # yms-update managed upstream begin\n server 127.0.0.1:8080 max_fails=1 fail_timeout=2s;\n server 127.0.0.1:8081 max_fails=1 fail_timeout=2s;\n # yms-update managed upstream end\n }\n}\n")
}
return hostnginx.Snapshot{Content: append([]byte(nil), content...), ActivePort: g.port}, nil
}
// Switch 直接更新活动端口并返回切换前快照。
func (g *fakeGateway) Switch(_ context.Context, port int) (hostnginx.Snapshot, error) {
previous, _ := g.Read()
g.port = port
return previous, nil
}
// fakeEngine containerengine.Engine 的内存实现,维护容器表。
type fakeEngine struct {
containers map[string]containerengine.Container
removed []string
}
func (e *fakeEngine) Ping(context.Context) error { return nil }
func (e *fakeEngine) PullImage(context.Context, string) error { return nil }
func (e *fakeEngine) LoadImage(context.Context, io.Reader) error { return nil }
func (e *fakeEngine) InspectImage(context.Context, string) (containerengine.Image, error) {
return containerengine.Image{}, nil
}
func (e *fakeEngine) CreateContainer(context.Context, containerengine.ContainerSpec) (containerengine.Container, error) {
return containerengine.Container{}, nil
}
func (e *fakeEngine) StartContainer(context.Context, 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, string, int) error { return nil }
func (e *fakeEngine) InspectContainer(_ context.Context, name string) (containerengine.Container, error) {
record, found := e.containers[name]
if !found {
return containerengine.Container{}, containerengine.ErrNotFound
}
return record, nil
}
func (e *fakeEngine) RemoveContainer(_ context.Context, name string, _ bool) error {
delete(e.containers, name)
e.removed = append(e.removed, name)
return nil
}
func (e *fakeEngine) Close() error { return nil }
var _ containerengine.Engine = (*fakeEngine)(nil)
+157
View File
@@ -0,0 +1,157 @@
// Package backendstatus 对 backend 组件的现场状态与 SQLite 事实来源做只读诊断,并按需执行自动修复。
//
// 诊断遵循“SQLite 是事实来源、外部系统是待校验状态”的原则:status 与 doctor 只读,
// 不修改任何现场;reconcile 默认只生成修复计划,仅在显式要求 apply 时执行经过明确授权的
// 自动修复动作,且每个修复动作都先写入事务再执行外部变更。
package backendstatus
import (
"context"
"errors"
"fmt"
"yms-daemon/internal/containerengine"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/systemd"
"yms-daemon/internal/transaction"
)
// gateway 抽象宿主 Nginx 配置的读取与切流,便于在测试中替换。
type gateway interface {
// Read 返回当前完整配置快照及其活动后端端口。
Read() (hostnginx.Snapshot, error)
// Switch 把活动后端端口切到指定端口并重载 Nginx。
Switch(context.Context, int) (hostnginx.Snapshot, error)
}
// Diagnoser 持有执行诊断与自动修复所需的全部依赖。
// 除 store、coordinator、gateway 外,其余依赖按组件运行类型选择性使用:container 使用
// enginenative 使用 units,未使用的依赖可以为 nil。
type Diagnoser struct {
// config 已校验的本地部署配置,决定组件运行类型与槽位信息。
config deploymentconfig.Config
// store 服务端 SQLite 事务存储,是诊断的事实来源。
store *transaction.Store
// coordinator 用于在 reconcile --apply 时以可恢复方式执行单个修复步骤。
coordinator *transaction.Coordinator
// engine 容器引擎,container 后端诊断时使用。
engine containerengine.Engine
// units systemd 管理器,native 后端诊断时使用。
units systemd.Manager
// gateway 宿主 Nginx 配置控制器,用于读取活动端口与执行切流修复。
gateway gateway
}
// New 构造一个 Diagnoser 并校验依赖。
// config 必须已经通过 deploymentconfig.Validatestore、coordinator、gateway 不能为空;
// 当 backend.type 为 container 时 engine 不能为空,为 native 时 units 不能为空。
func New(config deploymentconfig.Config, store *transaction.Store, coordinator *transaction.Coordinator, engine containerengine.Engine, units systemd.Manager, gateway gateway) (*Diagnoser, error) {
if err := config.Validate(); err != nil {
return nil, err
}
if store == nil || coordinator == nil || gateway == nil {
return nil, errors.New("backend status dependencies are required")
}
switch config.Backend.Type {
case deploymentconfig.BackendTypeContainer:
if engine == nil {
return nil, errors.New("container backend diagnosis requires a container engine")
}
case deploymentconfig.BackendTypeNative:
if units == nil {
return nil, errors.New("native backend diagnosis requires a systemd manager")
}
default:
return nil, fmt.Errorf("unsupported backend.type %q", config.Backend.Type)
}
return &Diagnoser{config: config, store: store, coordinator: coordinator, engine: engine, units: units, gateway: gateway}, nil
}
// Diagnose 执行只读诊断并返回结构化结果,不修改任何现场状态。
// 返回的 daemonapi.Diagnosis 中,Healthy 为 true 表示不存在需要人工处理的漂移项。
func (d *Diagnoser) Diagnose(ctx context.Context) (daemonapi.Diagnosis, error) {
switch d.config.Backend.Type {
case deploymentconfig.BackendTypeContainer:
return d.diagnoseContainer(ctx)
case deploymentconfig.BackendTypeNative:
return d.diagnoseNative(ctx)
default:
return daemonapi.Diagnosis{}, fmt.Errorf("unsupported backend.type %q", d.config.Backend.Type)
}
}
// Reconcile 生成修复计划(apply 为 false)或执行自动修复(apply 为 true)。
// apply 为 false 时行为与 Diagnose 一致,仅返回只读诊断;apply 为 true 时先诊断,
// 对每个可自动修复项执行修复动作,完成后重新诊断并返回修复后的结果。
func (d *Diagnoser) Reconcile(ctx context.Context, apply bool) (daemonapi.Diagnosis, error) {
if !apply {
return d.Diagnose(ctx)
}
switch d.config.Backend.Type {
case deploymentconfig.BackendTypeContainer:
return d.reconcileContainer(ctx)
case deploymentconfig.BackendTypeNative:
return d.reconcileNative(ctx)
default:
return daemonapi.Diagnosis{}, fmt.Errorf("unsupported backend.type %q", d.config.Backend.Type)
}
}
// otherPort 返回给定端口的另一个蓝绿槽位端口。
func otherPort(port int) int {
if port == deploymentconfig.BackendPort8080 {
return deploymentconfig.BackendPort8081
}
return deploymentconfig.BackendPort8080
}
// finding 内部诊断结论,比协议层的 DiagnosisItem 多携带一个可选的修复参数。
type finding struct {
// level 诊断级别,取值 daemonapi.DiagnosisLevelOK / DiagnosisLevelFixable / DiagnosisLevelDrift。
level string
// code 稳定标识,供测试与 reconcile 识别。
code string
// message 人类可读的诊断描述。
message string
// action 仅在 level 为 fixable 时给出建议动作描述。
action string
// fix 描述可自动修复动作,仅 fixable 项非 nil。
fix *fixAction
}
// fixAction 描述一个可自动修复动作所需的精确参数。
type fixAction struct {
// kind 修复动作种类,取值 fixSwitchPort 或 fixRemoveContainer。
kind string
// port 切流修复的目标端口。
port int
// container 容器移除修复的目标容器名。
container string
}
const (
// fixSwitchPort 表示把宿主 Nginx 切流到指定端口。
fixSwitchPort = "nginx-switch"
// fixRemoveContainer 表示移除残留的已停止容器。
fixRemoveContainer = "container-remove"
)
// toDiagnosis 把内部 finding 列表转换为协议层诊断结果,并据此计算 Healthy 标志。
func toDiagnosis(service string, runtimeType string, findings []finding) daemonapi.Diagnosis {
items := make([]daemonapi.DiagnosisItem, 0, len(findings))
healthy := true
for _, f := range findings {
if f.level == daemonapi.DiagnosisLevelDrift {
healthy = false
}
items = append(items, daemonapi.DiagnosisItem{
Level: f.level,
Code: f.code,
Message: f.message,
Action: f.action,
})
}
return daemonapi.Diagnosis{Service: service, Type: runtimeType, Healthy: healthy, Items: items}
}
+137
View File
@@ -0,0 +1,137 @@
package backendstatus
import (
"context"
"errors"
"fmt"
"os"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/systemd"
)
// 诊断项稳定标识,供测试与 reconcile 识别,不参与展示。
const (
codeNativeActivePortOK = "native_nginx_active_port"
codeNativeActiveUnitOK = "native_active_unit_running"
codeNativeActiveUnitDown = "native_active_unit_not_running"
codeNativeInactiveUnitOK = "native_inactive_unit_stopped"
codeNativeInactiveRunning = "native_inactive_unit_running"
codeNativeSlotJAROK = "native_slot_jar_link"
codeNativeSlotJARDrift = "native_slot_jar_link_drift"
)
// diagnoseNative 对 native 后端做只读诊断。
// native 没有部署单例记录,事实来源是宿主 Nginx 活动端口与 systemd 单元现场状态。
func (d *Diagnoser) diagnoseNative(ctx context.Context) (daemonapi.Diagnosis, error) {
findings, err := d.nativeGather(ctx)
if err != nil {
return daemonapi.Diagnosis{}, err
}
return toDiagnosis("backend", deploymentconfig.BackendTypeNative, findings), nil
}
// nativeGather 生成 native 后端的全部诊断项。
func (d *Diagnoser) nativeGather(ctx context.Context) ([]finding, error) {
before, err := d.gateway.Read()
if err != nil {
return nil, fmt.Errorf("read host Nginx configuration: %w", err)
}
activePort := before.ActivePort
findings := make([]finding, 0, 6)
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeNativeActivePortOK,
message: fmt.Sprintf("宿主 Nginx 指向后端端口 %d", activePort),
})
activeSlot, err := d.config.Backend.SlotForPort(activePort)
if err != nil {
return nil, err
}
inactiveSlot, err := d.config.Backend.SlotForPort(otherPort(activePort))
if err != nil {
return nil, err
}
activeUnit, err := d.units.Inspect(ctx, activeSlot.Unit)
if err != nil {
return nil, fmt.Errorf("inspect native active unit %s: %w", activeSlot.Unit, err)
}
if unitRunning(activeUnit) {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeNativeActiveUnitOK,
message: "活动单元 " + activeSlot.Unit + " 正在运行",
})
} else {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeNativeActiveUnitDown,
message: "活动单元 " + activeSlot.Unit + " 未运行(ActiveState=" + activeUnit.ActiveState + "",
})
}
inactiveUnit, err := d.units.Inspect(ctx, inactiveSlot.Unit)
if err != nil {
return nil, fmt.Errorf("inspect native inactive unit %s: %w", inactiveSlot.Unit, err)
}
if unitRunning(inactiveUnit) {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeNativeInactiveRunning,
message: "非活动单元 " + inactiveSlot.Unit + " 意外运行,两个槽位同时在线",
})
} else {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeNativeInactiveUnitOK,
message: "非活动单元 " + inactiveSlot.Unit + " 已停止",
})
}
if ok, detail := jarLinkStatus(activeSlot.JAR); ok {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelOK,
code: codeNativeSlotJAROK,
message: "活动槽位 JAR 链接有效:" + detail,
})
} else {
findings = append(findings, finding{
level: daemonapi.DiagnosisLevelDrift,
code: codeNativeSlotJARDrift,
message: "活动槽位 JAR 链接异常:" + detail,
})
}
return findings, nil
}
// unitRunning 判断 systemd 单元是否处于运行状态。
func unitRunning(unit systemd.Unit) bool {
return unit.ActiveState != "inactive" && unit.ActiveState != "failed"
}
// jarLinkStatus 检查槽位 JAR 路径是否为指向现存文件的符号链接。
// 返回 ok 与人类可读的详情;JAR 缺失、不是符号链接或目标不存在时返回 ok=false。
func jarLinkStatus(path string) (bool, string) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return false, "路径不存在"
}
if err != nil {
return false, "检查失败:" + err.Error()
}
if info.Mode()&os.ModeSymlink == 0 {
return false, "不是符号链接"
}
target, err := os.Readlink(path)
if err != nil {
return false, "读取链接目标失败:" + err.Error()
}
if _, err := os.Stat(path); err != nil {
return false, "链接目标不存在:" + target
}
return true, "指向 " + target
}
+187
View File
@@ -0,0 +1,187 @@
package backendstatus
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"strconv"
"yms-daemon/internal/containerengine"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/transaction"
)
// reconcileSource 表示对账修复事务的来源标识。
const reconcileSource = "local-cli"
// reconcileContainer 执行 container 后端的对账:只读时返回修复计划,apply 时执行自动修复。
func (d *Diagnoser) reconcileContainer(ctx context.Context) (daemonapi.Diagnosis, error) {
findings, err := d.containerGather(ctx)
if err != nil {
return daemonapi.Diagnosis{}, err
}
return d.applyFixes(ctx, deploymentconfig.BackendTypeContainer, findings)
}
// reconcileNative 执行 native 后端的对账。当前 native 没有可自动修复项,仅返回只读诊断。
func (d *Diagnoser) reconcileNative(ctx context.Context) (daemonapi.Diagnosis, error) {
findings, err := d.nativeGather(ctx)
if err != nil {
return daemonapi.Diagnosis{}, err
}
return d.applyFixes(ctx, deploymentconfig.BackendTypeNative, findings)
}
// applyFixes 对诊断结果中的可自动修复项执行修复动作。
// 若没有可修复项则直接返回只读诊断;否则创建对账事务,通过 coordinator 对每个修复动作
// 先记录意图、再执行、再核对,全部成功后把事务推进到 COMMITTED,最后重新诊断返回修复后状态。
func (d *Diagnoser) applyFixes(ctx context.Context, runtimeType string, findings []finding) (daemonapi.Diagnosis, error) {
var fixable []finding
for _, f := range findings {
if f.level == daemonapi.DiagnosisLevelFixable && f.fix != nil {
fixable = append(fixable, f)
}
}
if len(fixable) == 0 {
return toDiagnosis("backend", runtimeType, findings), nil
}
transactionID := rand.Text()
codes := make([]string, 0, len(fixable))
for _, f := range fixable {
codes = append(codes, f.code)
}
requestJSON, err := json.Marshal(struct {
Service string `json:"service"`
Type string `json:"type"`
Fixes []string `json:"fixes"`
}{"backend", runtimeType, codes})
if err != nil {
return daemonapi.Diagnosis{}, fmt.Errorf("encode reconcile request: %w", err)
}
if _, _, err := d.store.CreateTransaction(ctx, transaction.CreateRequest{
ID: transactionID,
IdempotencyKey: "backend:reconcile:" + rand.Text(),
Source: reconcileSource,
Service: "backend",
Request: requestJSON,
}); err != nil {
return daemonapi.Diagnosis{}, fmt.Errorf("create reconcile transaction: %w", err)
}
for _, f := range fixable {
intent, operation, err := d.fixOperation(f)
if err != nil {
_, _ = d.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error())
return daemonapi.Diagnosis{}, err
}
if _, err := d.coordinator.ExecuteStep(ctx, transactionID, intent, operation); err != nil {
_, _ = d.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error())
return daemonapi.Diagnosis{}, fmt.Errorf("apply reconcile fix %s: %w", f.code, err)
}
}
for _, next := range []transaction.State{
transaction.StateValidating,
transaction.StatePrepared,
transaction.StateStarting,
transaction.StateSwitching,
transaction.StateVerifying,
transaction.StateDraining,
transaction.StateCommitted,
} {
if _, err := d.store.Transition(ctx, transactionID, next, "reconcile"); err != nil {
return daemonapi.Diagnosis{}, fmt.Errorf("commit reconcile transaction: %w", err)
}
}
diagnosis, err := d.Diagnose(ctx)
if err != nil {
return daemonapi.Diagnosis{}, err
}
diagnosis.RepairApplied = true
diagnosis.RepairTransactionID = transactionID
return diagnosis, nil
}
// fixOperation 把一条可修复诊断项转换为对账事务中可执行的步骤意图与操作。
func (d *Diagnoser) fixOperation(f finding) (transaction.StepIntent, transaction.Operation, error) {
switch f.fix.kind {
case fixSwitchPort:
intent := reconcileIntent("reconcile.nginx.switch."+strconv.Itoa(f.fix.port), "switch host Nginx to committed backend port", struct {
Port int `json:"port"`
}{f.fix.port})
return intent, &switchNginxOperation{gateway: d.gateway, port: f.fix.port}, nil
case fixRemoveContainer:
intent := reconcileIntent("reconcile.container.remove."+f.fix.container, "remove residual stopped backend container", struct {
ContainerName string `json:"containerName"`
}{f.fix.container})
return intent, &removeContainerOperation{engine: d.engine, name: f.fix.container}, nil
default:
return transaction.StepIntent{}, nil, fmt.Errorf("unsupported reconcile fix kind %q", f.fix.kind)
}
}
// reconcileIntent 构造对账修复步骤的持久化意图。
func reconcileIntent(key, name string, value any) transaction.StepIntent {
payload, err := json.Marshal(value)
if err != nil {
panic(fmt.Sprintf("marshal reconcile intent: %v", err))
}
return transaction.StepIntent{Key: key, Name: name, Intent: payload}
}
// switchNginxOperation 把宿主 Nginx 切流到指定端口,实现 transaction.Operation。
type switchNginxOperation struct {
gateway gateway
port int
}
// Apply 执行宿主 Nginx 切流,切到当前已指向的端口时为空操作。
func (o *switchNginxOperation) Apply(ctx context.Context) error {
_, err := o.gateway.Switch(ctx, o.port)
return err
}
// Inspect 核对宿主 Nginx 当前活动端口是否等于目标端口。
func (o *switchNginxOperation) Inspect(ctx context.Context) (transaction.Inspection, error) {
snapshot, err := o.gateway.Read()
if err != nil {
return transaction.Inspection{}, err
}
if snapshot.ActivePort == o.port {
return transaction.Inspection{Status: transaction.InspectionApplied}, nil
}
return transaction.Inspection{Status: transaction.InspectionNotApplied}, nil
}
// removeContainerOperation 移除一个残留容器,实现 transaction.Operation。
type removeContainerOperation struct {
engine containerengine.Engine
name string
}
// Apply 强制移除目标容器,容器不存在时视为成功。
func (o *removeContainerOperation) Apply(ctx context.Context) error {
err := o.engine.RemoveContainer(ctx, o.name, true)
if errors.Is(err, containerengine.ErrNotFound) {
return nil
}
return err
}
// Inspect 核对目标容器是否已经不存在。
func (o *removeContainerOperation) Inspect(ctx context.Context) (transaction.Inspection, error) {
if _, err := o.engine.InspectContainer(ctx, o.name); errors.Is(err, containerengine.ErrNotFound) {
return transaction.Inspection{Status: transaction.InspectionApplied}, nil
} else if err != nil {
return transaction.Inspection{}, err
}
return transaction.Inspection{Status: transaction.InspectionNotApplied}, nil
}
var _ transaction.Operation = (*switchNginxOperation)(nil)
var _ transaction.Operation = (*removeContainerOperation)(nil)
+1 -1
View File
@@ -22,7 +22,7 @@ import (
// 若已存在相关活动事务,则尝试复用并续跑。
func (u *Updater) Restart(ctx context.Context, report ProgressReporter) (transaction.Transaction, error) {
if u.config.Backend.Type == deploymentconfig.BackendTypeContainer {
return transaction.Transaction{}, errors.New("restart is not implemented for container backend")
return u.RestartContainer(ctx, report)
}
reportProgress(report, Progress{Message: "Resolving the current native backend release"})
active, err := u.store.ActiveTransaction(ctx)
+11 -4
View File
@@ -76,6 +76,8 @@ type Updater struct {
containerTmpSource string
// containerTmpTarget 容器后端临时目录在容器内的目标路径。
containerTmpTarget string
// containerStopGraceSeconds 停止容器后端旧容器时,发送停止信号后到强制终止前的等待秒数。
containerStopGraceSeconds int
// logger 结构化日志记录器。
logger *slog.Logger
// drain 切换后旧单元/旧容器的排空时长。
@@ -189,13 +191,18 @@ func NewContainer(
if err != nil {
return nil, err
}
stopGraceSeconds := defaultContainerStopGraceSeconds
if config.Backend.StopGraceSeconds != nil {
stopGraceSeconds = *config.Backend.StopGraceSeconds
}
return &Updater{
config: config, workRoot: workRoot, store: store, coordinator: coordinator, gateway: gateway,
containerExecutor: executor, engine: engine, logger: logger, drain: drainDuration,
containerConfigSource: deploymentconfig.ContainerConfigSource,
containerConfigTarget: deploymentconfig.ContainerConfigTarget,
containerTmpSource: deploymentconfig.ContainerTmpSource,
containerTmpTarget: deploymentconfig.ContainerTmpTarget,
containerConfigSource: deploymentconfig.ContainerConfigSource,
containerConfigTarget: deploymentconfig.ContainerConfigTarget,
containerTmpSource: deploymentconfig.ContainerTmpSource,
containerTmpTarget: deploymentconfig.ContainerTmpTarget,
containerStopGraceSeconds: stopGraceSeconds,
}, nil
}
+2 -2
View File
@@ -132,8 +132,8 @@ type Engine interface {
StartContainer(context.Context, string) error
// ContainerLogs 读取指定容器的日志流。
ContainerLogs(context.Context, string) (io.ReadCloser, error)
// StopContainer 停止指定容器。
StopContainer(context.Context, string) error
// StopContainer 停止指定容器timeoutSeconds 是发送停止信号后到强制终止前的等待秒数
StopContainer(context.Context, string, int) error
// InspectContainer 检查容器并返回其运行时状态。
InspectContainer(context.Context, string) (Container, error)
// RemoveContainer 移除指定容器,force 决定是否强制移除。
+13 -12
View File
@@ -1,7 +1,6 @@
package containerengine
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -164,24 +163,26 @@ func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error
return nil
}
// ContainerLogs 读取指定容器的 stdout 与 stderr 日志,
// 将引擎的多路复用流解码后合并为一个只读流返回。
// ContainerLogs 跟随读取指定容器的 stdout 与 stderr 日志,将引擎的多路复用流
// 解码后合并为一个持续流返回,直到 ctx 取消或容器停止
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"})
stream, err := e.client.ContainerLogs(ctx, idOrName, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: 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
reader, writer := io.Pipe()
go func() {
_, copyErr := stdcopy.StdCopy(writer, writer, stream)
_ = stream.Close()
_ = writer.CloseWithError(copyErr)
}()
return reader, nil
}
// StopContainer 停止指定容器,参数可为容器 ID 或名称。
func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string) error {
if _, err := e.client.ContainerStop(ctx, idOrName, client.ContainerStopOptions{}); err != nil {
// timeoutSeconds 是发送停止信号后到强制终止前的等待秒数,传给引擎以在超时后强制终止。
func (e *MobyEngine) StopContainer(ctx context.Context, idOrName string, timeoutSeconds int) error {
if _, err := e.client.ContainerStop(ctx, idOrName, client.ContainerStopOptions{Timeout: &timeoutSeconds}); err != nil {
return engineError("stop container", err)
}
return nil
+42
View File
@@ -55,6 +55,48 @@ func Restart(ctx context.Context, socketPath string, service string, progress fu
return submit(ctx, socketPath, request, progress)
}
// Status 向守护进程查询后端当前状态,返回结构化诊断结果。
// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名。
// 当路径非法、连接失败或守护进程返回错误时返回 error。
func Status(ctx context.Context, socketPath string, service string) (daemonapi.Diagnosis, error) {
if !filepath.IsAbs(socketPath) {
return daemonapi.Diagnosis{}, errors.New("daemon socket path must be absolute")
}
return submitDiagnosis(ctx, socketPath, daemonapi.Request{Operation: daemonapi.OperationStatus, Service: service})
}
// Doctor 向守护进程提交一次只读诊断请求,返回全部漂移项与建议动作。
// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名。
// 当路径非法、连接失败或守护进程返回错误时返回 error。
func Doctor(ctx context.Context, socketPath string, service string) (daemonapi.Diagnosis, error) {
if !filepath.IsAbs(socketPath) {
return daemonapi.Diagnosis{}, errors.New("daemon socket path must be absolute")
}
return submitDiagnosis(ctx, socketPath, daemonapi.Request{Operation: daemonapi.OperationDoctor, Service: service})
}
// Reconcile 向守护进程提交对账请求,apply 为 true 时执行自动修复,否则只生成修复计划。
// socketPath 守护进程 Unix Socket 的绝对路径,service 是目标服务名。
// 当路径非法、连接失败或守护进程返回错误时返回 error。
func Reconcile(ctx context.Context, socketPath string, service string, apply bool) (daemonapi.Diagnosis, error) {
if !filepath.IsAbs(socketPath) {
return daemonapi.Diagnosis{}, errors.New("daemon socket path must be absolute")
}
return submitDiagnosis(ctx, socketPath, daemonapi.Request{Operation: daemonapi.OperationReconcile, Service: service, Apply: apply})
}
// submitDiagnosis 提交一次诊断类请求并解析结果中的诊断结构,供 status/doctor/reconcile 复用。
func submitDiagnosis(ctx context.Context, socketPath string, request daemonapi.Request) (daemonapi.Diagnosis, error) {
response, err := submit(ctx, socketPath, request, nil)
if err != nil {
return daemonapi.Diagnosis{}, err
}
if response.Diagnosis == nil {
return daemonapi.Diagnosis{}, errors.New("daemon returned no diagnosis result")
}
return *response.Diagnosis, nil
}
// submit 通过 Unix Socket 把 request 发送给守护进程并读取响应流,供本包各公开函数复用。
// 它会以换行分隔的 JSON 编码请求,写完后关闭写端以向对端发送结束信号,
// 随后循环解码响应:进度事件通过 progress 回调透传,最终结果则返回给调用方。
+51 -5
View File
@@ -38,6 +38,15 @@ type backendUpdater interface {
Restart(context.Context, backendupdate.ProgressReporter) (transaction.Transaction, error)
}
// backendDiagnoser 抽象了后端状态诊断与对账能力,供 status/doctor/reconcile 操作使用。
// 它由 backendstatus 包在运行时注入,便于在测试中用轻量替身替换。
type backendDiagnoser interface {
// Diagnose 执行只读诊断,返回结构化诊断结果。
Diagnose(context.Context) (daemonapi.Diagnosis, error)
// Reconcile 生成修复计划或执行自动修复,apply 为 true 时执行修复动作。
Reconcile(context.Context, bool) (daemonapi.Diagnosis, error)
}
// Server 持有本地 Unix Socket 并把收到的请求分派给事务编排器。
// 它负责连接的生命周期管理、请求解码与校验、执行进度回写以及最终结果的返回。
type Server struct {
@@ -45,24 +54,29 @@ type Server struct {
socketPath string
// updater 后端更新与重启动作的实际执行者。
updater backendUpdater
// diagnoser 后端状态诊断与对账动作的实际执行者。
diagnoser backendDiagnoser
// logger 用于记录服务运行与请求处理过程中的日志。
logger *slog.Logger
}
// New 构造一个 Server 实例并校验入参。
// socketPath 必须是规范化的绝对路径,updater 不能为空,logger 为空时回退为默认日志器。
// 当路径非法或 updater 为空时返回 error。
func New(socketPath string, updater backendUpdater, logger *slog.Logger) (*Server, error) {
// socketPath 必须是规范化的绝对路径,updater 与 diagnoser 不能为空,logger 为空时回退为默认日志器。
// 当路径非法或依赖为空时返回 error。
func New(socketPath string, updater backendUpdater, diagnoser backendDiagnoser, 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 diagnoser == nil {
return nil, errors.New("backend diagnoser is required")
}
if logger == nil {
logger = slog.Default()
}
return &Server{socketPath: socketPath, updater: updater, logger: logger}, nil
return &Server{socketPath: socketPath, updater: updater, diagnoser: diagnoser, logger: logger}, nil
}
// Serve 监听 Unix Socket 直到 ctx 被取消,返回前会等待所有连接处理完成并清理 Socket 文件。
@@ -176,8 +190,24 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) {
return
}
record, updateErr = s.updater.Restart(ctx, report)
case daemonapi.OperationStatus, daemonapi.OperationDoctor:
if request.InputType != "" || request.File != "" || request.ImageReference != "" || request.Apply {
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: request.Operation + " does not accept inputType, file, imageReference, or apply"})
return
}
diagnosis, diagnosisErr := s.diagnoser.Diagnose(ctx)
s.writeDiagnosis(ctx, connection, request.Operation, diagnosis, diagnosisErr)
return
case daemonapi.OperationReconcile:
if request.InputType != "" || request.File != "" || request.ImageReference != "" {
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "reconcile does not accept inputType, file, or imageReference"})
return
}
diagnosis, diagnosisErr := s.diagnoser.Reconcile(ctx, request.Apply)
s.writeDiagnosis(ctx, connection, request.Operation, diagnosis, diagnosisErr)
return
default:
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update or restart"})
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "operation must be update, restart, status, doctor, or reconcile"})
return
}
response := daemonapi.Response{Kind: daemonapi.ResponseResult, TransactionID: record.ID, State: string(record.State)}
@@ -197,6 +227,22 @@ func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response)
return json.NewEncoder(connection).Encode(response)
}
// writeDiagnosis 把 status/doctor/reconcile 的诊断结果写回客户端。
// 诊断失败时返回携带错误信息的响应并清空诊断结果,同时记录相应日志。
func (s *Server) writeDiagnosis(ctx context.Context, connection net.Conn, operation string, diagnosis daemonapi.Diagnosis, err error) {
response := daemonapi.Response{Kind: daemonapi.ResponseResult, Diagnosis: &diagnosis}
if err != nil {
response.Diagnosis = nil
response.Error = err.Error()
s.logger.ErrorContext(ctx, "backend diagnosis failed", "operation", operation, "error", err)
} else {
s.logger.InfoContext(ctx, "backend diagnosis completed", "operation", operation, "healthy", diagnosis.Healthy, "items", len(diagnosis.Items))
}
if writeErr := s.writeResponse(connection, response); writeErr != nil {
s.logger.ErrorContext(ctx, "write daemon diagnosis result", "operation", operation, "error", writeErr)
}
}
// decodeRequest 从 reader 中解码并校验一条客户端请求。
// 它限制请求体大小不超过 maximumRequestBytes,禁止未知字段,并要求请求只能包含一个 JSON 值,
// 且所有字符串字段不得带有前后空白字符;任一条不满足时返回 error。
+72 -5
View File
@@ -22,7 +22,7 @@ import (
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)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -65,7 +65,7 @@ func TestServerAcceptsBackendUpdateThroughUnixSocket(t *testing.T) {
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)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -93,7 +93,7 @@ func TestServerAcceptsDirectNativeJARThroughUnixSocket(t *testing.T) {
func TestServerAcceptsContainerImageThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{record: transaction.Transaction{ID: "transaction-container-01", State: transaction.StateCommitted}}
server, err := New(socketPath, updater, slog.New(slog.NewTextHandler(io.Discard, nil)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -121,7 +121,7 @@ func TestServerReturnsTransactionFailure(t *testing.T) {
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)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -143,7 +143,7 @@ func TestServerReturnsTransactionFailure(t *testing.T) {
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)))
server, err := New(socketPath, updater, &fakeDiagnoser{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create daemon server: %v", err)
}
@@ -168,6 +168,55 @@ func TestServerAcceptsBackendRestartThroughUnixSocket(t *testing.T) {
}
}
// TestServerReturnsStatusThroughUnixSocket 验证服务端能通过 Unix Socket 处理 status 请求,
// 并把诊断结果返回给客户端。
func TestServerReturnsStatusThroughUnixSocket(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{}
diagnosis := daemonapi.Diagnosis{Service: "backend", Type: "container", Healthy: true, Items: []daemonapi.DiagnosisItem{{Level: daemonapi.DiagnosisLevelOK, Code: "ok", Message: "ok"}}}
diagnoser := &fakeDiagnoser{diagnosis: diagnosis}
server, err := New(socketPath, updater, diagnoser, 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)
result, err := daemonclient.Status(context.Background(), socketPath, "backend")
if err != nil {
t.Fatalf("submit status: %v", err)
}
if result.Service != "backend" || !result.Healthy {
t.Fatalf("unexpected status result: %+v", result)
}
}
// TestServerReconcileForwardsApply 验证 reconcile --apply 会把 apply 标志正确传递给诊断器。
func TestServerReconcileForwardsApply(t *testing.T) {
socketPath := shortSocketPath(t)
updater := &fakeUpdater{}
diagnoser := &fakeDiagnoser{diagnosis: daemonapi.Diagnosis{Service: "backend", Type: "container", Healthy: true}}
server, err := New(socketPath, updater, diagnoser, 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)
if _, err := daemonclient.Reconcile(context.Background(), socketPath, "backend", true); err != nil {
t.Fatalf("submit reconcile apply: %v", err)
}
if !diagnoser.applied {
t.Fatal("reconcile --apply was not forwarded to diagnoser")
}
}
// shortSocketPath 创建一个短目录并返回其下的 daemon.sock 路径,用于规避 Unix Socket 路径长度上限。
// 目录会在测试结束时自动清理。
func shortSocketPath(t *testing.T) string {
@@ -259,3 +308,21 @@ func (u *fakeUpdater) Restart(_ context.Context, report backendupdate.ProgressRe
}
return u.record, u.err
}
// fakeDiagnoser backendDiagnoser 接口的测试替身,返回预设的空诊断结果。
type fakeDiagnoser struct {
diagnosis daemonapi.Diagnosis
err error
applied bool
}
// Diagnose 返回预设的诊断结果。
func (d *fakeDiagnoser) Diagnose(context.Context) (daemonapi.Diagnosis, error) {
return d.diagnosis, d.err
}
// Reconcile 返回预设的诊断结果,并记录 apply 标志。
func (d *fakeDiagnoser) Reconcile(_ context.Context, apply bool) (daemonapi.Diagnosis, error) {
d.applied = apply
return d.diagnosis, d.err
}
+7 -1
View File
@@ -101,6 +101,9 @@ type Backend struct {
ActiveJAR string `toml:"active_jar"`
// SystemctlPath systemctl 可执行文件的绝对路径。
SystemctlPath string `toml:"systemctl_path"`
// StopGraceSeconds 容器后端停止旧容器时,发送停止信号后到强制终止前的等待秒数。
// 可省略;省略时执行器使用默认值 30。原生后端不使用该字段。
StopGraceSeconds *int `toml:"stop_grace_seconds"`
// Slot 包含 8080、8081 两个受支持槽位的精确取值。
Slot BackendSlots `toml:"slot"`
}
@@ -187,7 +190,7 @@ func validateExactDocumentKeys(document []byte) error {
if err != nil {
return err
}
if err := rejectUnknownKeys(backend, "backend", "type", "release_dir", "active_jar", "systemctl_path", "slot"); err != nil {
if err := rejectUnknownKeys(backend, "backend", "type", "release_dir", "active_jar", "systemctl_path", "stop_grace_seconds", "slot"); err != nil {
return err
}
slots, err := exactTable(backend, "backend", "slot")
@@ -264,6 +267,9 @@ func (c Config) Validate() error {
if err := validateAbsolutePath("backend.systemctl_path", c.Backend.SystemctlPath); err != nil {
return err
}
if c.Backend.StopGraceSeconds != nil && *c.Backend.StopGraceSeconds <= 0 {
return errors.New("backend.stop_grace_seconds must be a positive integer")
}
switch c.Backend.Type {
case BackendTypeNative:
if c.Backend.Slot.Port8080.ContainerName != "" || c.Backend.Slot.Port8081.ContainerName != "" {
+22
View File
@@ -91,6 +91,28 @@ func TestLoadValidContainerConfiguration(t *testing.T) {
}
}
// TestLoadAcceptsStopGraceSeconds 验证 backend.stop_grace_seconds 配置为合法正整数时
// 能被接受,并解析到 Backend.StopGraceSeconds。
func TestLoadAcceptsStopGraceSeconds(t *testing.T) {
content := strings.Replace(validContainerConfig, "systemctl_path = \"/bin/systemctl\"", "systemctl_path = \"/bin/systemctl\"\nstop_grace_seconds = 45", 1)
config, err := Load(writeConfig(t, content))
if err != nil {
t.Fatalf("load configuration with stop_grace_seconds: %v", err)
}
if config.Backend.StopGraceSeconds == nil || *config.Backend.StopGraceSeconds != 45 {
t.Fatalf("unexpected stop_grace_seconds: %+v", config.Backend.StopGraceSeconds)
}
}
// TestLoadRejectsNonPositiveStopGraceSeconds 验证 backend.stop_grace_seconds 配置为非正整数时被拒绝。
func TestLoadRejectsNonPositiveStopGraceSeconds(t *testing.T) {
content := strings.Replace(validContainerConfig, "systemctl_path = \"/bin/systemctl\"", "systemctl_path = \"/bin/systemctl\"\nstop_grace_seconds = 0", 1)
_, err := Load(writeConfig(t, content))
if err == nil || !strings.Contains(err.Error(), "backend.stop_grace_seconds") {
t.Fatalf("expected stop_grace_seconds rejection, got %v", err)
}
}
// TestLoadRejectsChangedContainerName 验证容器槽位的 container_name 被篡改时会被拒绝,
// 且错误信息精确指向 backend.slot.8081.container_name 字段。
func TestLoadRejectsChangedContainerName(t *testing.T) {
+47
View File
@@ -1,11 +1,42 @@
# =============================================================================
# yms-daemon 本机部署配置示例
# =============================================================================
# 后端支持两种运行类型,通过 [backend] 的 type 字段切换:
# type = "native" 原生部署:systemd 双实例 JVM + 版本化 JAR
# type = "container" 容器部署:Docker standalone 蓝绿容器
#
# 快速切换步骤:
# 1. 注释掉当前使用的 [backend] 配置块,取消注释目标 [backend] 配置块;
# 2. 确认 [backend] 的 type 与目标一致(native 或 container);
# 3. 重启 yms-daemon.servicesystemctl restart yms-daemon.service。
#
# 重要约束:
# - daemon 不根据现场状态推断运行类型,type 必须与现场实际部署一致;
# - native ↔ container 属于运行类型迁移,必须走独立迁移事务,
# 不能只修改本配置绕过;迁移执行器尚未实现。
# =============================================================================
[daemon]
# 机器级环境标记,只接受精确值 dev 或 prod:
# dev = 容器镜像从 Registry pull(开发环境)
# prod = 容器镜像从离线 repack ZIP load(客户现场)
# 该字段不改变 native 更新、健康检查、切流、事务或回滚行为。
environment = "prod"
# =============================================================================
# 方式一:原生后端(默认启用)
# =============================================================================
[backend]
type = "native"
# 原生后端 JAR 版本目录与当前生效兼容入口,与现有现场保持一致,勿改。
release_dir = "/home/yms/lib/releases"
active_jar = "/home/yms/lib/glory-soft-yms.jar"
# systemctl 可执行文件的绝对路径,必须填写该服务器 command -v systemctl 的精确输出。
systemctl_path = "/bin/systemctl"
# 容器后端停止旧容器时,发送停止信号后到强制终止前的等待秒数。
# 仅容器后端生效(原生后端忽略);可省略,省略时默认 30 秒。
# 配置后会覆盖容器创建时设置的 9000 秒 StopTimeout,避免旧容器优雅停机长期不退。
# stop_grace_seconds = 30
[backend.slot.8080]
unit = "yms-backend@8080.service"
@@ -16,3 +47,19 @@ health_endpoint = "http://127.0.0.1:8080/yms/actuator/health"
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"
# =============================================================================
# 方式二:容器后端(切换到容器时,取消注释本块,并注释掉上面的方式一)
# =============================================================================
# [backend]
# type = "container"
# systemctl_path = "/bin/systemctl"
# stop_grace_seconds = 30
#
# [backend.slot.8080]
# container_name = "backend-8080"
# health_endpoint = "http://127.0.0.1:8080/yms/actuator/health"
#
# [backend.slot.8081]
# container_name = "backend-8081"
# health_endpoint = "http://127.0.0.1:8081/yms/actuator/health"