feat: complete daemon operations and recovery tooling
This commit is contained in:
@@ -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 交付尚未实现。
|
||||
@@ -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 的调用协议。
|
||||
@@ -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 的调用协议。
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 使用
|
||||
// engine,native 使用 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.Validate;store、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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 决定是否强制移除。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 回调透传,最终结果则返回给调用方。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.service:systemctl 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"
|
||||
|
||||
Reference in New Issue
Block a user