Compare commits
25 Commits
1abe8df6ee
...
eac689cf4b
| Author | SHA1 | Date | |
|---|---|---|---|
| eac689cf4b | |||
| afa371b250 | |||
| a37ee9d09d | |||
| b163b48ae3 | |||
| 0102fa6ce9 | |||
| a06771b708 | |||
| 67214dd858 | |||
| 68d067d744 | |||
| 076167d3ad | |||
| 3f13130b8a | |||
| a81f81e347 | |||
| 54567e243c | |||
| 99082fd8c3 | |||
| d2070c9d5b | |||
| 56e104ccf0 | |||
| 5a9a070160 | |||
| 40e314744a | |||
| 13da865566 | |||
| ed80e2b656 | |||
| 0f3e16ea41 | |||
| 14660657bd | |||
| 3f6b1cba57 | |||
| 6966bd19b2 | |||
| a909ecf245 | |||
| 83a1f05f1a |
@@ -5,6 +5,54 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-22 Node SSR 配置与 Nginx 双槽受控区块
|
||||
|
||||
### 已完成
|
||||
|
||||
- `deploymentconfig.Config` 新增精确的 `nodeSsr` camel 表。
|
||||
- Node SSR 仅接受 `container`,槽位固定为宿主端口 `18910`、`28910`。
|
||||
- 槽位容器名固定为 `node-ssr-18910`、`node-ssr-28910`。
|
||||
- 健康检查固定为 `http://127.0.0.1:18910/health` 与 `http://127.0.0.1:28910/health`。
|
||||
- `hostnginx` 新增 Node SSR 受控 upstream 区块解析、活动端口读取和硬切换渲染:
|
||||
`# yms-update managed nodeSsr upstream begin/end`。
|
||||
- Node SSR Nginx 逻辑入口端口为 `8910`;受控 upstream 后端为 `18910`、`28910`。
|
||||
- Node SSR upstream 名称冻结为 `ssr`,受控标记为 `# yms-update managed ssr upstream begin/end`。
|
||||
|
||||
### 尚未完成
|
||||
|
||||
- Node SSR 容器更新事务执行器、SQLite 部署记录、CLI `--service nodeSsr` 分派尚未实现。
|
||||
- Node SSR updater 接入后,必须复用上述 Nginx 受控区块,并在健康检查通过后切换活动槽位。
|
||||
- 执行器接入前,`nodeSsr` 表暂为可选;存在时严格校验,缺失时不影响现有 backend 配置启动。
|
||||
|
||||
### 提交
|
||||
|
||||
- `83a1f05 feat: add node SSR dual-slot deployment config`
|
||||
- `a909ecf feat: add Node SSR Nginx upstream switching`
|
||||
- `3f6b1cb fix: keep Node SSR config optional during rollout`
|
||||
- `ed80e2b feat: add Node SSR deployment transaction record`
|
||||
- `40e3147 feat: extend container executor for Node SSR`
|
||||
|
||||
### 事务底座
|
||||
|
||||
- SQLite schema version 升至 3,新增 `node_ssr_container_deployment` 单例表。
|
||||
- 新增 `NodeSsrContainerDeployment` 读写与原子提交方法,端口严格限制为 `18910/28910`。
|
||||
- 通用容器执行器允许 Node SSR 使用 `PORT`、`/health`、18910/28910,并支持无配置文件与无 bind mount 的容器。
|
||||
|
||||
### Sonar 清理
|
||||
|
||||
- Node SSR upstream 行解析拆分为单行解析函数,降低主解析函数复杂度。
|
||||
- Node SSR 部署记录字段校验改为有序字段列表,避免 map 遍历顺序不稳定。
|
||||
- 容器执行器的可选挂载路径校验提取为 `validateOptionalPathPair`,移除重复分支和单行嵌套判断。
|
||||
- `go test ./...`、`go vet ./...`、`git diff --check` 通过。
|
||||
- `d2070c9 refactor: simplify Node SSR parsing and validation`
|
||||
- `99082fd refactor: simplify optional container path validation`
|
||||
- `a81f81e refactor: split deployment configuration validation`
|
||||
- `3f13130 refactor: split container switch and commit steps`
|
||||
- `076167d refactor: split CLI diagnosis and status rendering`
|
||||
- `67214dd refactor: split container executor validation states`
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-22 对账结果可见性 + 容器镜像 tag 展示
|
||||
|
||||
### 诊断与对账输出
|
||||
@@ -127,3 +175,23 @@
|
||||
|
||||
- native 自动修复未实现(JAR 链接漂移修复需 filestore identity + release 校验)。
|
||||
- `coverage.out` 未重新生成(不含新包)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-22 Sonar 复杂度继续收敛
|
||||
|
||||
- 将部署配置的精确键校验拆为 root、daemon、backend、Node SSR 及槽位校验函数,保留原有字段与错误语义。
|
||||
- 将 backend 校验按 native/container 分支拆分,降低单方法认知复杂度。
|
||||
- 将 Node SSR Nginx marker 定位与 server 收集拆分,保留重复 marker、重复端口和槽位数量校验。
|
||||
- 将容器镜像解析的平台校验、tag 校验、仓库 digest 收集拆分,保留摘要唯一性要求。
|
||||
|
||||
验证:`GOCACHE=/tmp/yms-go-cache go test ./...` 通过。
|
||||
|
||||
### 本轮继续拆分
|
||||
|
||||
- daemon server 将请求读取、进度回调、更新/重启分派、诊断分派拆开,保留协议错误文本与响应行为。
|
||||
- `runServe` 将 native/container 运行时构建与资源关闭拆出,避免入口函数承担全部分支。
|
||||
- 容器槽位现场核对拆为容器检查、已提交部署校验和活动槽位选择,减少单函数分支嵌套。
|
||||
- 容器镜像更新拆为输入校验、事务恢复、镜像拉取、槽位准备和事务创建阶段,行为与幂等键保持不变。
|
||||
|
||||
验证:`GOCACHE=/tmp/yms-go-cache go test ./...`、`git diff --check` 通过。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module yms-daemon
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
|
||||
@@ -98,6 +98,8 @@ type Request struct {
|
||||
RestartPolicy containerengine.RestartPolicy
|
||||
// HealthEndpoint 后端 Actuator 健康检查的 HTTP 端点。
|
||||
HealthEndpoint string
|
||||
// HealthPath 健康检查路径;为空时使用 backend 默认路径。
|
||||
HealthPath string
|
||||
// StartLog 表示是否读取并回传容器启动日志。
|
||||
StartLog bool
|
||||
// LogReporter 用于回传容器启动日志的每一行。
|
||||
@@ -156,45 +158,54 @@ func (e *Executor) run(ctx context.Context, transactionID string, request Reques
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch record.State {
|
||||
case transaction.StateCreated:
|
||||
if _, err := e.store.Transition(ctx, transactionID, transaction.StateValidating, "backend container validation started"); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StateValidating:
|
||||
if err := e.validate(ctx, request); err != nil {
|
||||
_, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error())
|
||||
return errors.Join(err, transitionErr)
|
||||
}
|
||||
if _, err := e.store.Transition(ctx, transactionID, transaction.StatePrepared, "backend container inputs validated"); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StatePrepared:
|
||||
if err := e.prepare(ctx, transactionID, request); err != nil {
|
||||
return e.failUnlessRecoverable(ctx, transactionID, err)
|
||||
}
|
||||
if _, err := e.store.Transition(ctx, transactionID, transaction.StateStarting, "backend container prepared"); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StateStarting:
|
||||
// 重放 PREPARED 步骤会核对持久化意图,阻止恢复时换入另一组请求参数。
|
||||
if err := e.prepare(ctx, transactionID, request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.startAndCheck(ctx, transactionID, request); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := e.store.Transition(ctx, transactionID, transaction.StateSwitching, "backend container is healthy"); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StateSwitching:
|
||||
done, err := e.advanceState(ctx, transactionID, record.State, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("backend container executor cannot run transaction %s in state %s", transactionID, record.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) advanceState(ctx context.Context, transactionID string, state transaction.State, request Request) (bool, error) {
|
||||
switch state {
|
||||
case transaction.StateCreated:
|
||||
_, err := e.store.Transition(ctx, transactionID, transaction.StateValidating, "backend container validation started")
|
||||
return false, err
|
||||
case transaction.StateValidating:
|
||||
return false, e.validateAndPrepare(ctx, transactionID, request)
|
||||
case transaction.StatePrepared:
|
||||
if err := e.prepare(ctx, transactionID, request); err != nil {
|
||||
return false, e.failUnlessRecoverable(ctx, transactionID, err)
|
||||
}
|
||||
_, err := e.store.Transition(ctx, transactionID, transaction.StateStarting, "backend container prepared")
|
||||
return false, err
|
||||
case transaction.StateStarting:
|
||||
if err := e.prepare(ctx, transactionID, request); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := e.startAndCheck(ctx, transactionID, request); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err := e.store.Transition(ctx, transactionID, transaction.StateSwitching, "backend container is healthy")
|
||||
return false, err
|
||||
case transaction.StateSwitching:
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("backend container executor cannot run transaction %s in state %s", transactionID, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) validateAndPrepare(ctx context.Context, transactionID string, request Request) error {
|
||||
if err := e.validate(ctx, request); err != nil {
|
||||
_, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error())
|
||||
return errors.Join(err, transitionErr)
|
||||
}
|
||||
_, err := e.store.Transition(ctx, transactionID, transaction.StatePrepared, "backend container inputs validated")
|
||||
return err
|
||||
}
|
||||
|
||||
// failUnlessRecoverable 判断 prepare 阶段的错误是否可恢复。
|
||||
// 若 cause 是 UncertainStepError 或 ErrStepConflict,则直接原样返回(保留不确定性以便重放恢复);
|
||||
// 否则将事务标记为 StateFailed 并合并返回 cause 与状态迁移错误。
|
||||
@@ -319,6 +330,19 @@ func (e *Executor) streamContainerLogs(ctx context.Context, name string, report
|
||||
// validateRequest 对请求字段做静态校验,确保所有取值精确且自洽。
|
||||
// 任一字段不符合要求时返回描述性错误。
|
||||
func validateRequest(request Request) error {
|
||||
if err := validateImageAcquisition(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateImageIdentity(request); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateContainerRuntime(request); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateHealthEndpoint(request)
|
||||
}
|
||||
|
||||
func validateImageAcquisition(request Request) error {
|
||||
switch request.ImageAcquisition {
|
||||
case ImageAcquisitionLoad:
|
||||
if !filepath.IsAbs(request.ArchivePath) {
|
||||
@@ -335,6 +359,10 @@ func validateRequest(request Request) error {
|
||||
default:
|
||||
return fmt.Errorf("unsupported image acquisition: %q", request.ImageAcquisition)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateImageIdentity(request Request) error {
|
||||
if request.ImageReference == "" || strings.TrimSpace(request.ImageReference) != request.ImageReference {
|
||||
return errors.New("exact image reference is required")
|
||||
}
|
||||
@@ -347,30 +375,44 @@ func validateRequest(request Request) error {
|
||||
if request.ContainerName == "" || strings.TrimSpace(request.ContainerName) != request.ContainerName {
|
||||
return errors.New("exact container name is required")
|
||||
}
|
||||
if request.Port != 8080 && request.Port != 8081 {
|
||||
return fmt.Errorf("backend container port must be 8080 or 8081: %d", request.Port)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContainerRuntime(request Request) error {
|
||||
if request.Port != 8080 && request.Port != 8081 && request.Port != 18910 && request.Port != 28910 {
|
||||
return fmt.Errorf("container port must be 8080, 8081, 18910, or 28910: %d", request.Port)
|
||||
}
|
||||
if request.PortEnvironmentKey == "" || strings.Contains(request.PortEnvironmentKey, "=") || strings.TrimSpace(request.PortEnvironmentKey) != request.PortEnvironmentKey {
|
||||
return errors.New("exact port environment key is required")
|
||||
}
|
||||
if !filepath.IsAbs(request.ConfigSource) || !filepath.IsAbs(request.ConfigTarget) {
|
||||
return errors.New("backend configuration source and target must be absolute paths")
|
||||
if err := validateOptionalPathPair(request.ConfigSource, request.ConfigTarget, "backend configuration source and target"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(request.TmpSource) || !filepath.IsAbs(request.TmpTarget) {
|
||||
return errors.New("backend temporary source and target must be absolute paths")
|
||||
if err := validateOptionalPathPair(request.TmpSource, request.TmpTarget, "backend temporary source and target"); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.ConfigEnvironmentKey == "" || strings.Contains(request.ConfigEnvironmentKey, "=") || strings.TrimSpace(request.ConfigEnvironmentKey) != request.ConfigEnvironmentKey {
|
||||
return errors.New("exact backend configuration environment key is required")
|
||||
}
|
||||
if request.ConfigLocation == "" || strings.TrimSpace(request.ConfigLocation) != request.ConfigLocation {
|
||||
return errors.New("exact backend configuration location is required")
|
||||
if request.ConfigSource != "" {
|
||||
if request.ConfigEnvironmentKey == "" || strings.Contains(request.ConfigEnvironmentKey, "=") || strings.TrimSpace(request.ConfigEnvironmentKey) != request.ConfigEnvironmentKey {
|
||||
return errors.New("exact backend configuration environment key is required")
|
||||
}
|
||||
if request.ConfigLocation == "" || strings.TrimSpace(request.ConfigLocation) != request.ConfigLocation {
|
||||
return errors.New("exact backend configuration location is required")
|
||||
}
|
||||
}
|
||||
if err := validateRestartPolicy(request.RestartPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHealthEndpoint(request Request) error {
|
||||
path := request.HealthPath
|
||||
if path == "" {
|
||||
path = healthPath
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(request.HealthEndpoint)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != healthPath {
|
||||
return fmt.Errorf("health endpoint must be an HTTP URL with exact path %s", healthPath)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != path {
|
||||
return fmt.Errorf("health endpoint must be an HTTP URL with exact path %s", path)
|
||||
}
|
||||
if parsed.Port() != strconv.Itoa(request.Port) {
|
||||
return fmt.Errorf("health endpoint port must equal backend container port %d", request.Port)
|
||||
@@ -420,6 +462,17 @@ func directDirectory(path, description string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOptionalPathPair accepts two empty paths or two clean absolute paths.
|
||||
func validateOptionalPathPair(source string, target string, description string) error {
|
||||
if source == "" && target == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(source) || !filepath.IsAbs(target) {
|
||||
return fmt.Errorf("%s must be absolute paths", description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerSpec 根据请求构造后端容器的完整规格,包括名称、镜像引用、平台、环境变量、
|
||||
// 宿主机网络模式、重启策略、绑定挂载、用户与停止超时。
|
||||
func containerSpec(request Request) containerengine.ContainerSpec {
|
||||
@@ -427,23 +480,25 @@ func containerSpec(request Request) containerengine.ContainerSpec {
|
||||
if request.DisplayImageReference != "" {
|
||||
imageReference = request.DisplayImageReference
|
||||
}
|
||||
return containerengine.ContainerSpec{
|
||||
Name: request.ContainerName,
|
||||
ImageReference: imageReference,
|
||||
Platform: request.Platform,
|
||||
Environment: []string{
|
||||
request.PortEnvironmentKey + "=" + strconv.Itoa(request.Port),
|
||||
request.ConfigEnvironmentKey + "=" + request.ConfigLocation,
|
||||
},
|
||||
NetworkMode: hostNetworkMode,
|
||||
RestartPolicy: request.RestartPolicy,
|
||||
Mounts: []containerengine.Mount{
|
||||
{Type: bindMountType, Source: request.ConfigSource, Target: request.ConfigTarget, ReadOnly: true},
|
||||
{Type: bindMountType, Source: request.TmpSource, Target: request.TmpTarget},
|
||||
},
|
||||
spec := containerengine.ContainerSpec{
|
||||
Name: request.ContainerName,
|
||||
ImageReference: imageReference,
|
||||
Platform: request.Platform,
|
||||
Environment: []string{request.PortEnvironmentKey + "=" + strconv.Itoa(request.Port)},
|
||||
NetworkMode: hostNetworkMode,
|
||||
RestartPolicy: request.RestartPolicy,
|
||||
Mounts: nil,
|
||||
User: "0:0",
|
||||
StopTimeoutSeconds: containerStopTimeoutSeconds,
|
||||
}
|
||||
if request.ConfigSource != "" {
|
||||
spec.Environment = append(spec.Environment, request.ConfigEnvironmentKey+"="+request.ConfigLocation)
|
||||
spec.Mounts = append(spec.Mounts, containerengine.Mount{Type: bindMountType, Source: request.ConfigSource, Target: request.ConfigTarget, ReadOnly: true})
|
||||
}
|
||||
if request.TmpSource != "" {
|
||||
spec.Mounts = append(spec.Mounts, containerengine.Mount{Type: bindMountType, Source: request.TmpSource, Target: request.TmpTarget})
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// pullIntent 构造拉取并校验后端镜像步骤的持久化意图。
|
||||
|
||||
@@ -28,6 +28,8 @@ const (
|
||||
codeDeploymentConfigMismatch = "deployment_record_config_mismatch"
|
||||
)
|
||||
|
||||
const inactiveSlotContainerMessage = "非活动槽位容器 "
|
||||
|
||||
// diagnoseContainer 对 container 后端做只读诊断。
|
||||
// 事实来源是 backend_container_deployment 单例记录;现场状态由容器引擎与宿主 Nginx 提供。
|
||||
func (d *Diagnoser) diagnoseContainer(ctx context.Context) (daemonapi.Diagnosis, error) {
|
||||
@@ -143,7 +145,7 @@ func (d *Diagnoser) containerFindings(ctx context.Context, deployment transactio
|
||||
findings = append(findings, finding{
|
||||
level: daemonapi.DiagnosisLevelOK,
|
||||
code: codeInactiveContainerOK,
|
||||
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 不存在",
|
||||
message: inactiveSlotContainerMessage + inactiveSlot.ContainerName + " 不存在",
|
||||
})
|
||||
case inactiveErr != nil:
|
||||
return nil, fmt.Errorf("inspect inactive backend container %s: %w", inactiveSlot.ContainerName, inactiveErr)
|
||||
@@ -151,13 +153,13 @@ func (d *Diagnoser) containerFindings(ctx context.Context, deployment transactio
|
||||
findings = append(findings, finding{
|
||||
level: daemonapi.DiagnosisLevelDrift,
|
||||
code: codeInactiveContainerRunning,
|
||||
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 意外处于运行状态",
|
||||
message: inactiveSlotContainerMessage + inactiveSlot.ContainerName + " 意外处于运行状态",
|
||||
})
|
||||
default:
|
||||
findings = append(findings, finding{
|
||||
level: daemonapi.DiagnosisLevelFixable,
|
||||
code: codeInactiveContainerResidual,
|
||||
message: "非活动槽位容器 " + inactiveSlot.ContainerName + " 已停止,属于残留",
|
||||
message: inactiveSlotContainerMessage + inactiveSlot.ContainerName + " 已停止,属于残留",
|
||||
action: "移除残留的已停止容器 " + inactiveSlot.ContainerName,
|
||||
fix: &fixAction{kind: fixRemoveContainer, container: inactiveSlot.ContainerName},
|
||||
})
|
||||
|
||||
+205
-123
@@ -72,85 +72,111 @@ type persistedContainerRequest struct {
|
||||
// 是否转发容器启动日志;report 用于回传实时进度,可为 nil。返回值为本次更新
|
||||
// 对应的事务记录以及错误。若已存在同镜像的活动事务则复用续跑。
|
||||
func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference string, startLog bool, report ProgressReporter) (transaction.Transaction, error) {
|
||||
if err := u.validateImageUpdate(imageReference); err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
}
|
||||
if record, request, resumed, err := u.resumeContainerImage(ctx, imageReference, report); resumed || err != nil {
|
||||
return u.finishResumedContainer(ctx, record, request, err, report)
|
||||
}
|
||||
resolved, err := u.pullImageForUpdate(ctx, imageReference, report)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
}
|
||||
prepared, err := u.prepareContainerImage(ctx, imageReference, resolved, startLog)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
}
|
||||
return u.createContainerImageTransaction(ctx, prepared, report)
|
||||
}
|
||||
|
||||
func (u *Updater) validateImageUpdate(imageReference string) error {
|
||||
if u.containerExecutor == nil || u.engine == nil {
|
||||
return transaction.Transaction{}, errors.New("container backend updater is not configured")
|
||||
return errors.New("container backend updater is not configured")
|
||||
}
|
||||
if strings.TrimSpace(imageReference) != imageReference || imageReference == "" {
|
||||
return transaction.Transaction{}, errors.New("exact container image reference is required")
|
||||
}
|
||||
if err := u.validateContainerInputs(); err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return errors.New("exact container image reference is required")
|
||||
}
|
||||
return u.validateContainerInputs()
|
||||
}
|
||||
|
||||
func (u *Updater) resumeContainerImage(ctx context.Context, imageReference string, report ProgressReporter) (transaction.Transaction, persistedContainerRequest, bool, error) {
|
||||
active, err := u.store.ActiveTransaction(ctx)
|
||||
if err == nil {
|
||||
var request persistedContainerRequest
|
||||
if decodeErr := decodeContainerRequest(active.Request, &request); decodeErr != nil {
|
||||
return active, decodeErr
|
||||
}
|
||||
if active.Service != serviceBackend || request.InputType != inputTypeContainerImage {
|
||||
return active, &transaction.ActiveTransactionError{TransactionID: active.ID}
|
||||
}
|
||||
if request.ImageReference != imageReference {
|
||||
return active, &transaction.ActiveTransactionError{TransactionID: active.ID}
|
||||
}
|
||||
return u.runContainerUpdate(ctx, active, request, false, report)
|
||||
if errors.Is(err, transaction.ErrNotFound) {
|
||||
return transaction.Transaction{}, persistedContainerRequest{}, false, nil
|
||||
}
|
||||
if !errors.Is(err, transaction.ErrNotFound) {
|
||||
return transaction.Transaction{}, err
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, persistedContainerRequest{}, true, err
|
||||
}
|
||||
var request persistedContainerRequest
|
||||
if err := decodeContainerRequest(active.Request, &request); err != nil {
|
||||
return active, request, true, err
|
||||
}
|
||||
if active.Service != serviceBackend || request.InputType != inputTypeContainerImage || request.ImageReference != imageReference {
|
||||
return active, request, true, &transaction.ActiveTransactionError{TransactionID: active.ID}
|
||||
}
|
||||
return active, request, true, nil
|
||||
}
|
||||
|
||||
func (u *Updater) finishResumedContainer(ctx context.Context, record transaction.Transaction, request persistedContainerRequest, err error, report ProgressReporter) (transaction.Transaction, error) {
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
return u.runContainerUpdate(ctx, record, request, false, report)
|
||||
}
|
||||
|
||||
func (u *Updater) pullImageForUpdate(ctx context.Context, imageReference string, report ProgressReporter) (resolvedImage, error) {
|
||||
reportProgress(report, Progress{Message: "Pulling backend image " + imageReference})
|
||||
resolved, err := u.pullAndResolveImage(ctx, imageReference)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
if err == nil {
|
||||
reportProgress(report, Progress{Message: "Backend image resolved: " + resolved.ImmutableReference})
|
||||
}
|
||||
reportProgress(report, Progress{Message: "Backend image resolved: " + resolved.ImmutableReference})
|
||||
return resolved, err
|
||||
}
|
||||
|
||||
type preparedContainerImage struct {
|
||||
transactionID string
|
||||
request persistedContainerRequest
|
||||
resolved resolvedImage
|
||||
}
|
||||
|
||||
func (u *Updater) prepareContainerImage(ctx context.Context, imageReference string, resolved resolvedImage, startLog bool) (preparedContainerImage, error) {
|
||||
before, err := u.gateway.Read()
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
activeSlot, err := u.config.Backend.SlotForPort(before.ActivePort)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
deployment, deploymentErr := u.store.BackendContainerDeployment(ctx)
|
||||
hasDeployment := deploymentErr == nil
|
||||
if deploymentErr != nil && !errors.Is(deploymentErr, transaction.ErrNotFound) {
|
||||
return transaction.Transaction{}, deploymentErr
|
||||
return preparedContainerImage{}, deploymentErr
|
||||
}
|
||||
hasHistory, err := u.store.HasCommittedBackendContainerTransactionHistory(ctx)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
targetPort, targetSlot, previousContainer, err := u.resolveContainerSlots(
|
||||
ctx,
|
||||
before.ActivePort,
|
||||
activeSlot,
|
||||
deployment,
|
||||
hasDeployment,
|
||||
hasHistory,
|
||||
)
|
||||
targetPort, targetSlot, previousContainer, err := u.resolveContainerSlots(ctx, before.ActivePort, activeSlot, deployment, hasDeployment, hasHistory)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
afterContent, err := hostnginx.RenderBackendPort(before.Content, targetPort)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
transactionID := rand.Text()
|
||||
transactionRoot := filepath.Join(u.workRoot, transactionID)
|
||||
if err := os.MkdirAll(transactionRoot, 0o750); err != nil {
|
||||
return transaction.Transaction{}, fmt.Errorf("create container backend transaction directory: %w", err)
|
||||
return preparedContainerImage{}, fmt.Errorf("create container backend transaction directory: %w", err)
|
||||
}
|
||||
beforePath := filepath.Join(transactionRoot, "gateway.before.conf")
|
||||
afterPath := filepath.Join(transactionRoot, "gateway.after.conf")
|
||||
if err := writeImmutableFile(beforePath, before.Content, 0o640); err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
if err := writeImmutableFile(afterPath, afterContent, 0o640); err != nil {
|
||||
return transaction.Transaction{}, err
|
||||
return preparedContainerImage{}, err
|
||||
}
|
||||
request := persistedContainerRequest{
|
||||
InputType: inputTypeContainerImage, ImageReference: imageReference,
|
||||
@@ -162,12 +188,17 @@ func (u *Updater) UpdateContainerImage(ctx context.Context, imageReference strin
|
||||
GatewayBeforePath: beforePath, GatewayAfterPath: afterPath,
|
||||
GatewayReceiptPath: filepath.Join(transactionRoot, "gateway.applied"),
|
||||
}
|
||||
return preparedContainerImage{transactionID: transactionID, request: request, resolved: resolved}, nil
|
||||
}
|
||||
|
||||
func (u *Updater) createContainerImageTransaction(ctx context.Context, prepared preparedContainerImage, report ProgressReporter) (transaction.Transaction, error) {
|
||||
request, resolved := prepared.request, prepared.resolved
|
||||
content, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return transaction.Transaction{}, fmt.Errorf("encode container backend update request: %w", err)
|
||||
}
|
||||
record, created, err := u.store.CreateTransaction(ctx, transaction.CreateRequest{
|
||||
ID: transactionID, IdempotencyKey: serviceBackend + ":container:" + resolved.Digest,
|
||||
ID: prepared.transactionID, IdempotencyKey: serviceBackend + ":container:" + resolved.Digest,
|
||||
Source: sourceLocalCLI, Service: serviceBackend, Request: content,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -337,40 +368,19 @@ func (u *Updater) resolveContainerSlots(
|
||||
if err != nil {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", err
|
||||
}
|
||||
active, activeErr := u.engine.InspectContainer(ctx, activeSlot.ContainerName)
|
||||
if activeErr != nil && !errors.Is(activeErr, containerengine.ErrNotFound) {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("inspect active backend container %s: %w", activeSlot.ContainerName, activeErr)
|
||||
active, activeFound, err := u.inspectSlotContainer(ctx, activeSlot.ContainerName, "active")
|
||||
if err != nil {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", err
|
||||
}
|
||||
inactive, inactiveErr := u.engine.InspectContainer(ctx, inactiveSlot.ContainerName)
|
||||
if inactiveErr != nil && !errors.Is(inactiveErr, containerengine.ErrNotFound) {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("inspect inactive backend container %s: %w", inactiveSlot.ContainerName, inactiveErr)
|
||||
inactive, inactiveFound, err := u.inspectSlotContainer(ctx, inactiveSlot.ContainerName, "inactive")
|
||||
if err != nil {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", err
|
||||
}
|
||||
activeFound := activeErr == nil
|
||||
inactiveFound := inactiveErr == nil
|
||||
|
||||
if hasDeployment {
|
||||
if deployment.ActivePort != activePort {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("committed backend container port %d does not match gateway active port %d", deployment.ActivePort, activePort)
|
||||
}
|
||||
if deployment.ContainerName != activeSlot.ContainerName {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("committed backend container %s does not match gateway slot container %s", deployment.ContainerName, activeSlot.ContainerName)
|
||||
}
|
||||
if !activeFound {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("committed active backend container %s is missing", activeSlot.ContainerName)
|
||||
}
|
||||
if active.ID != deployment.ContainerID {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("active backend container %s identity does not match committed deployment", activeSlot.ContainerName)
|
||||
}
|
||||
if err := validateCommittedContainer(deployment, activePort, activeSlot, active, activeFound, hasDeployment); err != nil {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", err
|
||||
}
|
||||
|
||||
if activeFound {
|
||||
if !active.Running || active.Dead {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("active backend container %s is not running", activeSlot.ContainerName)
|
||||
}
|
||||
if inactiveFound && inactive.Running && !inactive.Dead {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("inactive backend container %s is unexpectedly running", inactiveSlot.ContainerName)
|
||||
}
|
||||
return inactivePort, inactiveSlot, activeSlot.ContainerName, nil
|
||||
return selectRunningContainerSlot(inactivePort, activeSlot, inactiveSlot, active, inactive, inactiveFound)
|
||||
}
|
||||
if hasHistory {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("active backend container %s is missing on a server with committed backend container transaction history", activeSlot.ContainerName)
|
||||
@@ -381,6 +391,46 @@ func (u *Updater) resolveContainerSlots(
|
||||
return inactivePort, inactiveSlot, "", nil
|
||||
}
|
||||
|
||||
func (u *Updater) inspectSlotContainer(ctx context.Context, name string, role string) (containerengine.Container, bool, error) {
|
||||
container, err := u.engine.InspectContainer(ctx, name)
|
||||
if errors.Is(err, containerengine.ErrNotFound) {
|
||||
return containerengine.Container{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return containerengine.Container{}, false, fmt.Errorf("inspect %s backend container %s: %w", role, name, err)
|
||||
}
|
||||
return container, true, nil
|
||||
}
|
||||
|
||||
func validateCommittedContainer(deployment transaction.BackendContainerDeployment, activePort int, activeSlot deploymentconfig.BackendSlot, active containerengine.Container, found bool, hasDeployment bool) error {
|
||||
if !hasDeployment {
|
||||
return nil
|
||||
}
|
||||
if deployment.ActivePort != activePort {
|
||||
return fmt.Errorf("committed backend container port %d does not match gateway active port %d", deployment.ActivePort, activePort)
|
||||
}
|
||||
if deployment.ContainerName != activeSlot.ContainerName {
|
||||
return fmt.Errorf("committed backend container %s does not match gateway slot container %s", deployment.ContainerName, activeSlot.ContainerName)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("committed active backend container %s is missing", activeSlot.ContainerName)
|
||||
}
|
||||
if active.ID != deployment.ContainerID {
|
||||
return fmt.Errorf("active backend container %s identity does not match committed deployment", activeSlot.ContainerName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectRunningContainerSlot(inactivePort int, activeSlot, inactiveSlot deploymentconfig.BackendSlot, active, inactive containerengine.Container, inactiveFound bool) (int, deploymentconfig.BackendSlot, string, error) {
|
||||
if !active.Running || active.Dead {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("active backend container %s is not running", activeSlot.ContainerName)
|
||||
}
|
||||
if inactiveFound && inactive.Running && !inactive.Dead {
|
||||
return 0, deploymentconfig.BackendSlot{}, "", fmt.Errorf("inactive backend container %s is unexpectedly running", inactiveSlot.ContainerName)
|
||||
}
|
||||
return inactivePort, inactiveSlot, activeSlot.ContainerName, nil
|
||||
}
|
||||
|
||||
// resolvedImage 拉取并解析后得到的镜像信息。
|
||||
type resolvedImage struct {
|
||||
// ImmutableReference 带摘要的不可变镜像引用。
|
||||
@@ -395,13 +445,10 @@ type resolvedImage struct {
|
||||
// 信息,随后从仓库摘要中解析出唯一的不可变引用。若仓库存在多个匹配摘要则报错,
|
||||
// 以保证后续部署所用的引用是确定且唯一的。
|
||||
func (u *Updater) pullAndResolveImage(ctx context.Context, imageReference string) (resolvedImage, error) {
|
||||
named, err := reference.ParseNormalizedNamed(imageReference)
|
||||
named, err := parseTaggedImageReference(imageReference)
|
||||
if err != nil {
|
||||
return resolvedImage{}, fmt.Errorf("parse container image reference: %w", err)
|
||||
}
|
||||
if _, ok := named.(reference.Tagged); !ok {
|
||||
return resolvedImage{}, errors.New("--container-image requires a tag-qualified image reference")
|
||||
}
|
||||
if err := u.engine.Ping(ctx); err != nil {
|
||||
return resolvedImage{}, err
|
||||
}
|
||||
@@ -412,15 +459,41 @@ func (u *Updater) pullAndResolveImage(ctx context.Context, imageReference string
|
||||
if err != nil {
|
||||
return resolvedImage{}, fmt.Errorf("inspect pulled backend image: %w", err)
|
||||
}
|
||||
if image.Platform.OS == "" || image.Platform.Architecture == "" {
|
||||
return resolvedImage{}, errors.New("pulled backend image does not report an exact platform")
|
||||
if err := validateImagePlatform(image.Platform); err != nil {
|
||||
return resolvedImage{}, err
|
||||
}
|
||||
repository := reference.TrimNamed(named).Name()
|
||||
immutableReference, digest, err := resolveRepositoryDigest(image.RepoDigests, repository)
|
||||
if err != nil {
|
||||
return resolvedImage{}, err
|
||||
}
|
||||
return resolvedImage{ImmutableReference: immutableReference, Digest: digest, Platform: image.Platform}, nil
|
||||
}
|
||||
|
||||
func parseTaggedImageReference(imageReference string) (reference.Named, error) {
|
||||
named, err := reference.ParseNormalizedNamed(imageReference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := named.(reference.Tagged); !ok {
|
||||
return nil, errors.New("--container-image requires a tag-qualified image reference")
|
||||
}
|
||||
return named, nil
|
||||
}
|
||||
|
||||
func validateImagePlatform(platform containerengine.Platform) error {
|
||||
if platform.OS == "" || platform.Architecture == "" {
|
||||
return errors.New("pulled backend image does not report an exact platform")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveRepositoryDigest(values []string, repository string) (string, string, error) {
|
||||
matches := make(map[string]string)
|
||||
for _, value := range image.RepoDigests {
|
||||
for _, value := range values {
|
||||
digested, err := reference.ParseNormalizedNamed(value)
|
||||
if err != nil {
|
||||
return resolvedImage{}, fmt.Errorf("parse pulled repository digest %q: %w", value, err)
|
||||
return "", "", fmt.Errorf("parse pulled repository digest %q: %w", value, err)
|
||||
}
|
||||
withDigest, ok := digested.(reference.Digested)
|
||||
if !ok || reference.TrimNamed(digested).Name() != repository {
|
||||
@@ -429,15 +502,15 @@ func (u *Updater) pullAndResolveImage(ctx context.Context, imageReference string
|
||||
matches[withDigest.Digest().String()] = value
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return resolvedImage{}, fmt.Errorf("pulled backend image has no repository digest for %s", repository)
|
||||
return "", "", fmt.Errorf("pulled backend image has no repository digest for %s", repository)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return resolvedImage{}, fmt.Errorf("pulled backend image has multiple repository digests for %s", repository)
|
||||
return "", "", fmt.Errorf("pulled backend image has multiple repository digests for %s", repository)
|
||||
}
|
||||
for digest, immutableReference := range matches {
|
||||
return resolvedImage{ImmutableReference: immutableReference, Digest: digest, Platform: image.Platform}, nil
|
||||
return immutableReference, digest, nil
|
||||
}
|
||||
return resolvedImage{}, errors.New("repository digest resolution produced no result")
|
||||
return "", "", errors.New("repository digest resolution produced no result")
|
||||
}
|
||||
|
||||
// runContainerUpdate 根据事务当前状态执行容器后端更新的核心流程:启动目标容器,
|
||||
@@ -517,53 +590,15 @@ func (u *Updater) switchAndCommitContainer(ctx context.Context, transactionID st
|
||||
}
|
||||
switch record.State {
|
||||
case transaction.StateSwitching:
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Switching host Nginx backend traffic to port %d", request.TargetPort)})
|
||||
operation := &gatewayOperation{controller: u.gateway, before: before, after: after, receiptPath: request.GatewayReceiptPath}
|
||||
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, containerGatewaySwitchIntent(request), operation); err != nil {
|
||||
return u.rollbackContainer(ctx, transactionID, request, before, after, err)
|
||||
}
|
||||
if _, err := u.store.Transition(ctx, transactionID, transaction.StateVerifying, "host Nginx now routes backend traffic to the healthy container slot"); err != nil {
|
||||
if err := u.switchContainerTraffic(ctx, transactionID, request, before, after, report); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StateVerifying:
|
||||
message := "container backend switch verified"
|
||||
if request.PreviousContainer != "" {
|
||||
message += "; previous container draining"
|
||||
}
|
||||
if _, err := u.store.Transition(ctx, transactionID, transaction.StateDraining, message); err != nil {
|
||||
if err := u.markContainerDraining(ctx, transactionID, request); err != nil {
|
||||
return err
|
||||
}
|
||||
case transaction.StateDraining:
|
||||
if request.PreviousContainer != "" {
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Draining previous backend container for %s", u.drain)})
|
||||
if err := waitContext(ctx, u.drain); err != nil {
|
||||
return err
|
||||
}
|
||||
operation := &containerStopOperation{engine: u.engine, name: request.PreviousContainer, stopGraceSeconds: u.containerStopGraceSeconds}
|
||||
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, stopPreviousContainerIntent(request), operation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
target, err := u.engine.InspectContainer(ctx, request.TargetContainer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect target backend container before commit: %w", err)
|
||||
}
|
||||
if !target.Running || target.Dead || target.ID == "" {
|
||||
return fmt.Errorf("target backend container %s is not running with an exact identity", request.TargetContainer)
|
||||
}
|
||||
if target.ImageReference != request.ImmutableReference && target.ImageReference != request.ImageReference {
|
||||
return fmt.Errorf("target backend container %s image reference does not match the transaction", request.TargetContainer)
|
||||
}
|
||||
_, err = u.store.CommitBackendContainerDeployment(ctx, transactionID, transaction.BackendContainerDeployment{
|
||||
ActivePort: request.TargetPort,
|
||||
ContainerName: request.TargetContainer,
|
||||
ImageDigest: request.ImageDigest,
|
||||
ContainerID: target.ID,
|
||||
}, "container backend update committed")
|
||||
if err == nil {
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateCommitted, Message: "Container backend update committed"})
|
||||
}
|
||||
return err
|
||||
return u.drainAndCommitContainer(ctx, transactionID, request, report)
|
||||
case transaction.StateCommitted:
|
||||
return nil
|
||||
case transaction.StateRollingBack:
|
||||
@@ -574,6 +609,53 @@ func (u *Updater) switchAndCommitContainer(ctx context.Context, transactionID st
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) switchContainerTraffic(ctx context.Context, transactionID string, request persistedContainerRequest, before hostnginx.Snapshot, after hostnginx.Snapshot, report ProgressReporter) error {
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateSwitching, Message: fmt.Sprintf("Switching host Nginx backend traffic to port %d", request.TargetPort)})
|
||||
operation := &gatewayOperation{controller: u.gateway, before: before, after: after, receiptPath: request.GatewayReceiptPath}
|
||||
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, containerGatewaySwitchIntent(request), operation); err != nil {
|
||||
return u.rollbackContainer(ctx, transactionID, request, before, after, err)
|
||||
}
|
||||
_, err := u.store.Transition(ctx, transactionID, transaction.StateVerifying, "host Nginx now routes backend traffic to the healthy container slot")
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *Updater) markContainerDraining(ctx context.Context, transactionID string, request persistedContainerRequest) error {
|
||||
message := "container backend switch verified"
|
||||
if request.PreviousContainer != "" {
|
||||
message += "; previous container draining"
|
||||
}
|
||||
_, err := u.store.Transition(ctx, transactionID, transaction.StateDraining, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *Updater) drainAndCommitContainer(ctx context.Context, transactionID string, request persistedContainerRequest, report ProgressReporter) error {
|
||||
if request.PreviousContainer != "" {
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateDraining, Message: fmt.Sprintf("Draining previous backend container for %s", u.drain)})
|
||||
if err := waitContext(ctx, u.drain); err != nil {
|
||||
return err
|
||||
}
|
||||
operation := &containerStopOperation{engine: u.engine, name: request.PreviousContainer, stopGraceSeconds: u.containerStopGraceSeconds}
|
||||
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, stopPreviousContainerIntent(request), operation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
target, err := u.engine.InspectContainer(ctx, request.TargetContainer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect target backend container before commit: %w", err)
|
||||
}
|
||||
if !target.Running || target.Dead || target.ID == "" {
|
||||
return fmt.Errorf("target backend container %s is not running with an exact identity", request.TargetContainer)
|
||||
}
|
||||
if target.ImageReference != request.ImmutableReference && target.ImageReference != request.ImageReference {
|
||||
return fmt.Errorf("target backend container %s image reference does not match the transaction", request.TargetContainer)
|
||||
}
|
||||
_, err = u.store.CommitBackendContainerDeployment(ctx, transactionID, transaction.BackendContainerDeployment{ActivePort: request.TargetPort, ContainerName: request.TargetContainer, ImageDigest: request.ImageDigest, ContainerID: target.ID}, "container backend update committed")
|
||||
if err == nil {
|
||||
reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateCommitted, Message: "Container backend update committed"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// rollbackContainer 执行容器后端补偿:恢复 Nginx 上游、停止被补偿的目标容器,并
|
||||
// 将事务迁入 RolledBack 状态。cause 为触发补偿的原始错误,会与补偿过程中的错误
|
||||
// 合并后返回。
|
||||
|
||||
@@ -130,84 +130,23 @@ func (s *Server) Serve(ctx context.Context) error {
|
||||
// 最终无论成功失败都会写入一条 ResponseResult 响应并记录日志。
|
||||
func (s *Server) handle(ctx context.Context, connection net.Conn) {
|
||||
defer connection.Close()
|
||||
_ = connection.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
request, err := decodeRequest(connection)
|
||||
_ = connection.SetReadDeadline(time.Time{})
|
||||
request, err := s.readRequest(connection)
|
||||
if err != nil {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: err.Error()})
|
||||
s.writeError(connection, err.Error())
|
||||
return
|
||||
}
|
||||
if request.Service != "backend" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "service must be backend"})
|
||||
s.writeError(connection, "service must be backend")
|
||||
return
|
||||
}
|
||||
progressWritable := true
|
||||
report := func(progress backendupdate.Progress) {
|
||||
if !progressWritable {
|
||||
return
|
||||
}
|
||||
err := s.writeResponse(connection, daemonapi.Response{
|
||||
Kind: daemonapi.ResponseProgress,
|
||||
TransactionID: progress.TransactionID,
|
||||
State: string(progress.State),
|
||||
Message: progress.Message,
|
||||
})
|
||||
if err != nil {
|
||||
progressWritable = false
|
||||
s.logger.WarnContext(ctx, "write daemon update progress", "error", err)
|
||||
}
|
||||
progress := s.progressReporter(ctx, connection)
|
||||
if request.Operation == daemonapi.OperationStatus || request.Operation == daemonapi.OperationDoctor || request.Operation == daemonapi.OperationReconcile {
|
||||
s.handleDiagnosis(ctx, connection, request)
|
||||
return
|
||||
}
|
||||
var record transaction.Transaction
|
||||
var updateErr error
|
||||
switch request.Operation {
|
||||
case daemonapi.OperationUpdate:
|
||||
switch request.InputType {
|
||||
case daemonapi.InputTypeRepackZIP:
|
||||
if request.File == "" || request.ImageReference != "" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "repack-zip requires file and does not accept imageReference"})
|
||||
return
|
||||
}
|
||||
record, updateErr = s.updater.UpdateRepack(ctx, request.File, report)
|
||||
case daemonapi.InputTypeNativeJAR:
|
||||
if request.File == "" || request.ImageReference != "" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "native-jar requires file and does not accept imageReference"})
|
||||
return
|
||||
}
|
||||
record, updateErr = s.updater.UpdateNativeJAR(ctx, request.File, report)
|
||||
case daemonapi.InputTypeContainerImage:
|
||||
if request.File != "" || request.ImageReference == "" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "container-image requires imageReference and does not accept file"})
|
||||
return
|
||||
}
|
||||
record, updateErr = s.updater.UpdateContainerImage(ctx, request.ImageReference, request.StartLog, report)
|
||||
default:
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "inputType must be repack-zip, native-jar, or container-image"})
|
||||
return
|
||||
}
|
||||
case daemonapi.OperationRestart:
|
||||
if request.InputType != "" || request.File != "" || request.ImageReference != "" {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: "restart does not accept inputType or file"})
|
||||
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, restart, status, doctor, or reconcile"})
|
||||
record, updateErr := s.handleBackendOperation(ctx, request, progress)
|
||||
if updateErr != nil && record.ID == "" {
|
||||
s.writeError(connection, updateErr.Error())
|
||||
return
|
||||
}
|
||||
response := daemonapi.Response{Kind: daemonapi.ResponseResult, TransactionID: record.ID, State: string(record.State)}
|
||||
@@ -222,6 +161,82 @@ func (s *Server) handle(ctx context.Context, connection net.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) readRequest(connection net.Conn) (daemonapi.Request, error) {
|
||||
_ = connection.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
request, err := decodeRequest(connection)
|
||||
_ = connection.SetReadDeadline(time.Time{})
|
||||
return request, err
|
||||
}
|
||||
|
||||
func (s *Server) writeError(connection net.Conn, message string) {
|
||||
_ = s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseResult, Error: message})
|
||||
}
|
||||
|
||||
func (s *Server) progressReporter(ctx context.Context, connection net.Conn) backendupdate.ProgressReporter {
|
||||
progressWritable := true
|
||||
return func(progress backendupdate.Progress) {
|
||||
if !progressWritable {
|
||||
return
|
||||
}
|
||||
err := s.writeResponse(connection, daemonapi.Response{Kind: daemonapi.ResponseProgress, TransactionID: progress.TransactionID, State: string(progress.State), Message: progress.Message})
|
||||
if err != nil {
|
||||
progressWritable = false
|
||||
s.logger.WarnContext(ctx, "write daemon update progress", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBackendOperation(ctx context.Context, request daemonapi.Request, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
switch request.Operation {
|
||||
case daemonapi.OperationUpdate:
|
||||
return s.handleUpdate(ctx, request, report)
|
||||
case daemonapi.OperationRestart:
|
||||
if request.InputType != "" || request.File != "" || request.ImageReference != "" {
|
||||
return transaction.Transaction{}, errors.New("restart does not accept inputType or file")
|
||||
}
|
||||
return s.updater.Restart(ctx, report)
|
||||
default:
|
||||
return transaction.Transaction{}, errors.New("operation must be update, restart, status, doctor, or reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(ctx context.Context, request daemonapi.Request, report backendupdate.ProgressReporter) (transaction.Transaction, error) {
|
||||
switch request.InputType {
|
||||
case daemonapi.InputTypeRepackZIP:
|
||||
if request.File == "" || request.ImageReference != "" {
|
||||
return transaction.Transaction{}, errors.New("repack-zip requires file and does not accept imageReference")
|
||||
}
|
||||
return s.updater.UpdateRepack(ctx, request.File, report)
|
||||
case daemonapi.InputTypeNativeJAR:
|
||||
if request.File == "" || request.ImageReference != "" {
|
||||
return transaction.Transaction{}, errors.New("native-jar requires file and does not accept imageReference")
|
||||
}
|
||||
return s.updater.UpdateNativeJAR(ctx, request.File, report)
|
||||
case daemonapi.InputTypeContainerImage:
|
||||
if request.File != "" || request.ImageReference == "" {
|
||||
return transaction.Transaction{}, errors.New("container-image requires imageReference and does not accept file")
|
||||
}
|
||||
return s.updater.UpdateContainerImage(ctx, request.ImageReference, request.StartLog, report)
|
||||
default:
|
||||
return transaction.Transaction{}, errors.New("inputType must be repack-zip, native-jar, or container-image")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDiagnosis(ctx context.Context, connection net.Conn, request daemonapi.Request) {
|
||||
if request.InputType != "" || request.File != "" || request.ImageReference != "" || (request.Operation != daemonapi.OperationReconcile && request.Apply) {
|
||||
s.writeError(connection, request.Operation+" does not accept inputType, file, imageReference, or apply")
|
||||
return
|
||||
}
|
||||
var diagnosis daemonapi.Diagnosis
|
||||
var err error
|
||||
if request.Operation == daemonapi.OperationReconcile {
|
||||
diagnosis, err = s.diagnoser.Reconcile(ctx, request.Apply)
|
||||
} else {
|
||||
diagnosis, err = s.diagnoser.Diagnose(ctx)
|
||||
}
|
||||
s.writeDiagnosis(ctx, connection, request.Operation, diagnosis, err)
|
||||
}
|
||||
|
||||
// writeResponse 把一条响应以换行分隔的 JSON 编码写入连接,失败时返回底层写入错误。
|
||||
func (s *Server) writeResponse(connection net.Conn, response daemonapi.Response) error {
|
||||
return json.NewEncoder(connection).Encode(response)
|
||||
|
||||
@@ -34,6 +34,10 @@ const (
|
||||
BackendPort8080 = 8080
|
||||
// BackendPort8081 蓝绿发布中 8081 槽位的端口号。
|
||||
BackendPort8081 = 8081
|
||||
// NodeSsrPort18910 Node SSR 的第一个宿主槽位端口。
|
||||
NodeSsrPort18910 = 18910
|
||||
// NodeSsrPort28910 Node SSR 的第二个宿主槽位端口。
|
||||
NodeSsrPort28910 = 28910
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -57,6 +61,14 @@ const (
|
||||
containerName8080 = "backend-8080"
|
||||
// containerName8081 容器后端 8081 槽位的容器名称。
|
||||
containerName8081 = "backend-8081"
|
||||
// nodeSsrContainerName18910 Node SSR 18910 槽位的容器名称。
|
||||
nodeSsrContainerName18910 = "node-ssr-18910"
|
||||
// nodeSsrContainerName28910 Node SSR 28910 槽位的容器名称。
|
||||
nodeSsrContainerName28910 = "node-ssr-28910"
|
||||
// nodeSsrHealthURL18910 Node SSR 18910 槽位的健康检查地址。
|
||||
nodeSsrHealthURL18910 = "http://127.0.0.1:18910/health"
|
||||
// nodeSsrHealthURL28910 Node SSR 28910 槽位的健康检查地址。
|
||||
nodeSsrHealthURL28910 = "http://127.0.0.1:28910/health"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -83,6 +95,8 @@ type Config struct {
|
||||
Daemon Daemon `toml:"daemon"`
|
||||
// Backend 记录显式选定的后端运行时及其蓝绿槽位。
|
||||
Backend Backend `toml:"backend"`
|
||||
// NodeSsr 记录 Node SSR 容器运行时及其蓝绿槽位。
|
||||
NodeSsr NodeSsr `toml:"nodeSsr"`
|
||||
}
|
||||
|
||||
// Daemon 描述与具体组件运行时无关的机器级行为。
|
||||
@@ -129,6 +143,28 @@ type BackendSlot struct {
|
||||
HealthEndpoint string `toml:"health_endpoint"`
|
||||
}
|
||||
|
||||
// NodeSsr 描述 Node SSR 容器运行时及其蓝绿槽位。
|
||||
type NodeSsr struct {
|
||||
// Type 当前只接受 container。
|
||||
Type string `toml:"type"`
|
||||
// StopGraceSeconds 停止旧容器时的优雅退出等待秒数,可省略。
|
||||
StopGraceSeconds *int `toml:"stop_grace_seconds"`
|
||||
// Slot 包含 18910、28910 两个宿主端口槽位。
|
||||
Slot NodeSsrSlots `toml:"slot"`
|
||||
}
|
||||
|
||||
// NodeSsrSlots 列出 Node SSR 的两个精确宿主端口槽位。
|
||||
type NodeSsrSlots struct {
|
||||
Port18910 NodeSsrSlot `toml:"18910"`
|
||||
Port28910 NodeSsrSlot `toml:"28910"`
|
||||
}
|
||||
|
||||
// NodeSsrSlot 包含一个 Node SSR 容器槽位的容器名与健康检查地址。
|
||||
type NodeSsrSlot struct {
|
||||
ContainerName string `toml:"container_name"`
|
||||
HealthEndpoint string `toml:"health_endpoint"`
|
||||
}
|
||||
|
||||
// Load 打开 path 指向的部署配置文件,进行严格的 TOML 解码与契约校验,返回解析后的 Config。
|
||||
// 它要求 path 是绝对路径且为普通文件,解码时禁用未知字段(任何大小写、拼写或层级不一致的
|
||||
// 键都会被拒绝),解码后调用 Validate 精确校验取值。任一步失败都会返回带上下文的 error。
|
||||
@@ -176,16 +212,31 @@ func validateExactDocumentKeys(document []byte) error {
|
||||
if err := toml.Unmarshal(document, &root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectUnknownKeys(root, "", "daemon", "backend"); err != nil {
|
||||
if err := validateRootKeys(root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDaemonKeys(root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBackendKeys(root); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateOptionalNodeSsrKeys(root)
|
||||
}
|
||||
|
||||
func validateRootKeys(root map[string]any) error {
|
||||
return rejectUnknownKeys(root, "", "daemon", "backend", "nodeSsr")
|
||||
}
|
||||
|
||||
func validateDaemonKeys(root map[string]any) error {
|
||||
daemon, err := exactTable(root, "", "daemon")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectUnknownKeys(daemon, "daemon", "environment"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectUnknownKeys(daemon, "daemon", "environment")
|
||||
}
|
||||
|
||||
func validateBackendKeys(root map[string]any) error {
|
||||
backend, err := exactTable(root, "", "backend")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -197,15 +248,41 @@ func validateExactDocumentKeys(document []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectUnknownKeys(slots, "backend.slot", "8080", "8081"); err != nil {
|
||||
return validateSlotKeys(slots, "backend.slot", []string{"8080", "8081"}, []string{"unit", "jar", "container_name", "health_endpoint"})
|
||||
}
|
||||
|
||||
func validateOptionalNodeSsrKeys(root map[string]any) error {
|
||||
nodeSsrValue, found := root["nodeSsr"]
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if _, ok := nodeSsrValue.(map[string]any); !ok {
|
||||
return errors.New("nodeSsr must be a table")
|
||||
}
|
||||
nodeSsr, err := exactTable(root, "", "nodeSsr")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, port := range []string{"8080", "8081"} {
|
||||
slot, err := exactTable(slots, "backend.slot", port)
|
||||
if err := rejectUnknownKeys(nodeSsr, "nodeSsr", "type", "stop_grace_seconds", "slot"); err != nil {
|
||||
return err
|
||||
}
|
||||
nodeSlots, err := exactTable(nodeSsr, "nodeSsr", "slot")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlotKeys(nodeSlots, "nodeSsr.slot", []string{"18910", "28910"}, []string{"container_name", "health_endpoint"})
|
||||
}
|
||||
|
||||
func validateSlotKeys(slots map[string]any, parent string, ports []string, allowed []string) error {
|
||||
if err := rejectUnknownKeys(slots, parent, ports...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, port := range ports {
|
||||
slot, err := exactTable(slots, parent, port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectUnknownKeys(slot, "backend.slot."+port, "unit", "jar", "container_name", "health_endpoint"); err != nil {
|
||||
if err := rejectUnknownKeys(slot, parent+"."+port, allowed...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -259,51 +336,92 @@ func rejectUnknownKeys(table map[string]any, parent string, allowed ...string) e
|
||||
// 它依次校验 daemon.environment、backend.systemctl_path 的绝对路径约束,再按 backend.type
|
||||
// 分支精确校验原生或容器槽位的 unit、jar、container_name、health_endpoint 等字段。
|
||||
func (c Config) Validate() error {
|
||||
switch c.Daemon.Environment {
|
||||
case EnvironmentDev, EnvironmentProd:
|
||||
default:
|
||||
if c.Daemon.Environment != EnvironmentDev && c.Daemon.Environment != EnvironmentProd {
|
||||
return fmt.Errorf("daemon.environment must be %q or %q", EnvironmentDev, EnvironmentProd)
|
||||
}
|
||||
if err := validateAbsolutePath("backend.systemctl_path", c.Backend.SystemctlPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Backend.StopGraceSeconds != nil && *c.Backend.StopGraceSeconds <= 0 {
|
||||
if err := validateBackend(c.Backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateNodeSsr(c.NodeSsr)
|
||||
}
|
||||
|
||||
func validateBackend(backend Backend) error {
|
||||
if backend.StopGraceSeconds != nil && *backend.StopGraceSeconds <= 0 {
|
||||
return errors.New("backend.stop_grace_seconds must be a positive integer")
|
||||
}
|
||||
switch c.Backend.Type {
|
||||
switch backend.Type {
|
||||
case BackendTypeNative:
|
||||
if c.Backend.Slot.Port8080.ContainerName != "" || c.Backend.Slot.Port8081.ContainerName != "" {
|
||||
return errors.New("native backend slots do not accept container_name")
|
||||
}
|
||||
if c.Backend.ReleaseDir != nativeReleaseDir {
|
||||
return fmt.Errorf("backend.release_dir must be %q", nativeReleaseDir)
|
||||
}
|
||||
if c.Backend.ActiveJAR != nativeActiveJAR {
|
||||
return fmt.Errorf("backend.active_jar must be %q", nativeActiveJAR)
|
||||
}
|
||||
if err := validateNativeSlot("backend.slot.8080", c.Backend.Slot.Port8080, nativeUnit8080, nativeSlotJAR8080, nativeHealthURL8080); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNativeSlot("backend.slot.8081", c.Backend.Slot.Port8081, nativeUnit8081, nativeSlotJAR8081, nativeHealthURL8081); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateNativeBackend(backend)
|
||||
case BackendTypeContainer:
|
||||
if c.Backend.ReleaseDir != "" || c.Backend.ActiveJAR != "" {
|
||||
return errors.New("container backend does not accept release_dir or active_jar")
|
||||
}
|
||||
if c.Backend.Slot.Port8080.Unit != "" || c.Backend.Slot.Port8080.JAR != "" || c.Backend.Slot.Port8081.Unit != "" || c.Backend.Slot.Port8081.JAR != "" {
|
||||
return errors.New("container backend slots do not accept unit or jar")
|
||||
}
|
||||
if err := validateContainerSlot("backend.slot.8080", c.Backend.Slot.Port8080, containerName8080, nativeHealthURL8080); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateContainerSlot("backend.slot.8081", c.Backend.Slot.Port8081, containerName8081, nativeHealthURL8081); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateContainerBackend(backend)
|
||||
default:
|
||||
return fmt.Errorf("backend.type must be %q or %q", BackendTypeNative, BackendTypeContainer)
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func validateNativeBackend(backend Backend) error {
|
||||
if backend.Slot.Port8080.ContainerName != "" || backend.Slot.Port8081.ContainerName != "" {
|
||||
return errors.New("native backend slots do not accept container_name")
|
||||
}
|
||||
if backend.ReleaseDir != nativeReleaseDir {
|
||||
return fmt.Errorf("backend.release_dir must be %q", nativeReleaseDir)
|
||||
}
|
||||
if backend.ActiveJAR != nativeActiveJAR {
|
||||
return fmt.Errorf("backend.active_jar must be %q", nativeActiveJAR)
|
||||
}
|
||||
return validateNativeBackendSlots(backend.Slot)
|
||||
}
|
||||
|
||||
func validateContainerBackend(backend Backend) error {
|
||||
if backend.ReleaseDir != "" || backend.ActiveJAR != "" {
|
||||
return errors.New("container backend does not accept release_dir or active_jar")
|
||||
}
|
||||
if backend.Slot.Port8080.Unit != "" || backend.Slot.Port8080.JAR != "" || backend.Slot.Port8081.Unit != "" || backend.Slot.Port8081.JAR != "" {
|
||||
return errors.New("container backend slots do not accept unit or jar")
|
||||
}
|
||||
if err := validateContainerSlot("backend.slot.8080", backend.Slot.Port8080, containerName8080, nativeHealthURL8080); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateContainerSlot("backend.slot.8081", backend.Slot.Port8081, containerName8081, nativeHealthURL8081)
|
||||
}
|
||||
|
||||
func validateNativeBackendSlots(slots BackendSlots) error {
|
||||
if err := validateNativeSlot("backend.slot.8080", slots.Port8080, nativeUnit8080, nativeSlotJAR8080, nativeHealthURL8080); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateNativeSlot("backend.slot.8081", slots.Port8081, nativeUnit8081, nativeSlotJAR8081, nativeHealthURL8081)
|
||||
}
|
||||
|
||||
func validateNodeSsr(node NodeSsr) error {
|
||||
if node.Type == "" {
|
||||
return nil
|
||||
}
|
||||
if node.Type != BackendTypeContainer {
|
||||
return fmt.Errorf("nodeSsr.type must be %q", BackendTypeContainer)
|
||||
}
|
||||
if node.StopGraceSeconds != nil && *node.StopGraceSeconds <= 0 {
|
||||
return errors.New("nodeSsr.stop_grace_seconds must be a positive integer")
|
||||
}
|
||||
if err := validateNodeSsrSlot("nodeSsr.slot.18910", node.Slot.Port18910, nodeSsrContainerName18910, nodeSsrHealthURL18910); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateNodeSsrSlot("nodeSsr.slot.28910", node.Slot.Port28910, nodeSsrContainerName28910, nodeSsrHealthURL28910)
|
||||
}
|
||||
|
||||
// SlotForNodeSsrPort 返回 Node SSR 宿主端口对应的槽位配置。
|
||||
func (n NodeSsr) SlotForNodeSsrPort(port int) (NodeSsrSlot, error) {
|
||||
switch port {
|
||||
case NodeSsrPort18910:
|
||||
return n.Slot.Port18910, nil
|
||||
case NodeSsrPort28910:
|
||||
return n.Slot.Port28910, nil
|
||||
default:
|
||||
return NodeSsrSlot{}, fmt.Errorf("unsupported Node SSR port: %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
// SlotForPort 返回 port 对应的后端槽位配置,仅支持 BackendPort8080 或 BackendPort8081,
|
||||
@@ -346,6 +464,17 @@ func validateContainerSlot(field string, slot BackendSlot, containerName string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNodeSsrSlot 校验 Node SSR 槽位的容器名称和健康检查地址。
|
||||
func validateNodeSsrSlot(field string, slot NodeSsrSlot, containerName string, endpoint string) error {
|
||||
if slot.ContainerName != containerName {
|
||||
return fmt.Errorf("%s.container_name must be %q", field, containerName)
|
||||
}
|
||||
if slot.HealthEndpoint != endpoint {
|
||||
return fmt.Errorf("%s.health_endpoint must be %q", field, endpoint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAbsolutePath 校验 value 是一个非空、无首尾空白、经过 Clean 且不含 NUL 字节的绝对路径,
|
||||
// 用于保证配置中的路径标识符不会被意外篡改。field 用于拼出错误信息。
|
||||
func validateAbsolutePath(field string, value string) error {
|
||||
|
||||
@@ -26,6 +26,17 @@ 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"
|
||||
|
||||
[nodeSsr]
|
||||
type = "container"
|
||||
|
||||
[nodeSsr.slot.18910]
|
||||
container_name = "node-ssr-18910"
|
||||
health_endpoint = "http://127.0.0.1:18910/health"
|
||||
|
||||
[nodeSsr.slot.28910]
|
||||
container_name = "node-ssr-28910"
|
||||
health_endpoint = "http://127.0.0.1:28910/health"
|
||||
`
|
||||
|
||||
// validContainerConfig 一份满足容器后端部署契约的示例 TOML 配置,供各测试用例共享。
|
||||
@@ -43,6 +54,17 @@ 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"
|
||||
|
||||
[nodeSsr]
|
||||
type = "container"
|
||||
|
||||
[nodeSsr.slot.18910]
|
||||
container_name = "node-ssr-18910"
|
||||
health_endpoint = "http://127.0.0.1:18910/health"
|
||||
|
||||
[nodeSsr.slot.28910]
|
||||
container_name = "node-ssr-28910"
|
||||
health_endpoint = "http://127.0.0.1:28910/health"
|
||||
`
|
||||
|
||||
// TestLoadValidNativeConfiguration 验证合法原生配置能被完整加载,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package hostnginx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
nodeSsrManagedBegin = "# yms-update managed ssr upstream begin"
|
||||
nodeSsrManagedEnd = "# yms-update managed ssr upstream end"
|
||||
nodeSsrPort18910 = 18910
|
||||
nodeSsrPort28910 = 28910
|
||||
)
|
||||
|
||||
// ActiveNodeSsrPort 从 Node SSR 受控 upstream 区块读取唯一活动槽位端口。
|
||||
func ActiveNodeSsrPort(content []byte) (int, error) {
|
||||
block, err := parseNodeSsrBlock(content)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
active := 0
|
||||
for _, server := range block.servers {
|
||||
if !server.commented {
|
||||
if active != 0 {
|
||||
return 0, errors.New("managed Nginx upstream contains more than one active Node SSR server")
|
||||
}
|
||||
active = server.port
|
||||
}
|
||||
}
|
||||
if active == 0 {
|
||||
return 0, errors.New("managed Nginx upstream does not contain an active Node SSR server")
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// RenderNodeSsrPort 只切换 Node SSR 受控 upstream 区块中的活动服务器行。
|
||||
func RenderNodeSsrPort(content []byte, activePort int) ([]byte, error) {
|
||||
if activePort != nodeSsrPort18910 && activePort != nodeSsrPort28910 {
|
||||
return nil, fmt.Errorf("host Nginx Node SSR port must be 18910 or 28910: %d", activePort)
|
||||
}
|
||||
block, err := parseNodeSsrBlock(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, server := range block.servers {
|
||||
indentLength := len(block.lines[server.line]) - len(strings.TrimLeft(block.lines[server.line], " \t"))
|
||||
indent := block.lines[server.line][:indentLength]
|
||||
serverText := strings.TrimPrefix(strings.TrimSpace(block.lines[server.line]), "# ")
|
||||
if server.port == activePort {
|
||||
block.lines[server.line] = indent + serverText
|
||||
} else {
|
||||
block.lines[server.line] = indent + "# " + serverText
|
||||
}
|
||||
}
|
||||
rendered := []byte(strings.Join(block.lines, "\n"))
|
||||
if _, err := ActiveNodeSsrPort(rendered); err != nil {
|
||||
return nil, fmt.Errorf("validate rendered host Nginx Node SSR upstream: %w", err)
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
type nodeSsrBlock struct {
|
||||
lines []string
|
||||
servers []nodeSsrServer
|
||||
}
|
||||
|
||||
type nodeSsrServer struct {
|
||||
line int
|
||||
port int
|
||||
commented bool
|
||||
}
|
||||
|
||||
func parseNodeSsrBlock(content []byte) (nodeSsrBlock, error) {
|
||||
if len(content) == 0 {
|
||||
return nodeSsrBlock{}, errors.New("host Nginx configuration is empty")
|
||||
}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
begin, end, err := findNodeSsrMarkers(lines)
|
||||
if err != nil {
|
||||
return nodeSsrBlock{}, err
|
||||
}
|
||||
servers, err := collectNodeSsrServers(lines, begin, end)
|
||||
if err != nil {
|
||||
return nodeSsrBlock{}, err
|
||||
}
|
||||
return nodeSsrBlock{lines: lines, servers: servers}, nil
|
||||
}
|
||||
|
||||
func findNodeSsrMarkers(lines []string) (int, int, error) {
|
||||
begin, end := -1, -1
|
||||
for index, line := range lines {
|
||||
switch strings.TrimSpace(line) {
|
||||
case nodeSsrManagedBegin:
|
||||
if begin != -1 {
|
||||
return 0, 0, errors.New("host Nginx configuration contains duplicate Node SSR upstream begin markers")
|
||||
}
|
||||
begin = index
|
||||
case nodeSsrManagedEnd:
|
||||
if end != -1 {
|
||||
return 0, 0, errors.New("host Nginx configuration contains duplicate Node SSR upstream end markers")
|
||||
}
|
||||
end = index
|
||||
}
|
||||
}
|
||||
if begin == -1 || end <= begin {
|
||||
return 0, 0, errors.New("host Nginx configuration requires one ordered Node SSR upstream marker pair")
|
||||
}
|
||||
return begin, end, nil
|
||||
}
|
||||
|
||||
func collectNodeSsrServers(lines []string, begin, end int) ([]nodeSsrServer, error) {
|
||||
servers := make([]nodeSsrServer, 0, 2)
|
||||
seen := make(map[int]struct{}, 2)
|
||||
for index := begin + 1; index < end; index++ {
|
||||
server, found, err := parseNodeSsrServer(lines[index], index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[server.port]; exists {
|
||||
return nil, fmt.Errorf("managed Node SSR upstream contains duplicate port %d", server.port)
|
||||
}
|
||||
seen[server.port] = struct{}{}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
if len(servers) != 2 {
|
||||
return nil, fmt.Errorf("managed Node SSR upstream must contain exactly two servers, got %d", len(servers))
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// parseNodeSsrServer parses one server line inside the managed Node SSR block.
|
||||
func parseNodeSsrServer(line string, lineIndex int) (nodeSsrServer, bool, error) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
commented := strings.HasPrefix(trimmed, "# server ")
|
||||
active := strings.HasPrefix(trimmed, "server ")
|
||||
if !commented && !active {
|
||||
if trimmed != "" {
|
||||
return nodeSsrServer{}, false, fmt.Errorf("managed Node SSR upstream contains an unexpected line: %q", trimmed)
|
||||
}
|
||||
return nodeSsrServer{}, false, nil
|
||||
}
|
||||
serverText := strings.TrimPrefix(trimmed, "# ")
|
||||
fields := strings.Fields(serverText)
|
||||
if len(fields) != 4 || fields[0] != "server" || fields[2] != "max_fails=1" || fields[3] != "fail_timeout=2s;" {
|
||||
return nodeSsrServer{}, false, fmt.Errorf("managed Node SSR upstream server line has an unsupported format: %q", trimmed)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(fields[1])
|
||||
if err != nil {
|
||||
return nodeSsrServer{}, false, fmt.Errorf("parse managed Node SSR upstream address %q: %w", fields[1], err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || (port != nodeSsrPort18910 && port != nodeSsrPort28910) {
|
||||
return nodeSsrServer{}, false, fmt.Errorf("managed Node SSR upstream contains unsupported port %q", portText)
|
||||
}
|
||||
return nodeSsrServer{line: lineIndex, port: port, commented: commented}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package hostnginx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const nodeSsrConfiguration = `events {}
|
||||
# yms-update managed ssr upstream begin
|
||||
# server 127.0.0.1:18910 max_fails=1 fail_timeout=2s;
|
||||
server 127.0.0.1:28910 max_fails=1 fail_timeout=2s;
|
||||
# yms-update managed ssr upstream end
|
||||
`
|
||||
|
||||
func TestActiveNodeSsrPort(t *testing.T) {
|
||||
port, err := ActiveNodeSsrPort([]byte(nodeSsrConfiguration))
|
||||
if err != nil || port != nodeSsrPort28910 {
|
||||
t.Fatalf("unexpected active Node SSR port: port=%d err=%v", port, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNodeSsrPort(t *testing.T) {
|
||||
rendered, err := RenderNodeSsrPort([]byte(nodeSsrConfiguration), nodeSsrPort18910)
|
||||
if err != nil {
|
||||
t.Fatalf("render Node SSR port: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(rendered), "\nserver 127.0.0.1:18910") || !strings.Contains(string(rendered), "\n# server 127.0.0.1:28910") {
|
||||
t.Fatalf("unexpected Node SSR rendering: %s", rendered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const nodeSsrService = "nodeSsr"
|
||||
|
||||
// NodeSsrContainerDeployment 保存 Node SSR 最近一次提交的活动槽位。
|
||||
type NodeSsrContainerDeployment struct {
|
||||
ActivePort int
|
||||
ContainerName string
|
||||
ImageDigest string
|
||||
ContainerID string
|
||||
TransactionID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NodeSsrContainerDeployment 返回当前 Node SSR 部署记录。
|
||||
func (s *Store) NodeSsrContainerDeployment(ctx context.Context) (NodeSsrContainerDeployment, error) {
|
||||
return scanNodeSsrContainerDeployment(s.db.QueryRowContext(ctx, `
|
||||
SELECT active_port, container_name, image_digest, container_id, transaction_id, updated_at
|
||||
FROM node_ssr_container_deployment WHERE singleton_id = 1`))
|
||||
}
|
||||
|
||||
// CommitNodeSsrContainerDeployment 原子写入 Node SSR 部署记录并提交事务。
|
||||
func (s *Store) CommitNodeSsrContainerDeployment(ctx context.Context, transactionID string, deployment NodeSsrContainerDeployment, message string) (Transaction, error) {
|
||||
if transactionID == "" || strings.TrimSpace(transactionID) != transactionID {
|
||||
return Transaction{}, errors.New("exact transaction ID is required")
|
||||
}
|
||||
if err := validateNodeSsrContainerDeployment(deployment); err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("begin Node SSR container deployment commit: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
record, err := getTransactionByID(ctx, tx, transactionID)
|
||||
if err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
if record.Service != nodeSsrService {
|
||||
return Transaction{}, fmt.Errorf("transaction %s service must be %s", transactionID, nodeSsrService)
|
||||
}
|
||||
if record.State != StateDraining {
|
||||
return Transaction{}, &TransitionError{From: record.State, To: StateCommitted}
|
||||
}
|
||||
now := s.now().UTC()
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO node_ssr_container_deployment
|
||||
(singleton_id, active_port, container_name, image_digest, container_id, transaction_id, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(singleton_id) DO UPDATE SET
|
||||
active_port=excluded.active_port, container_name=excluded.container_name,
|
||||
image_digest=excluded.image_digest, container_id=excluded.container_id,
|
||||
transaction_id=excluded.transaction_id, updated_at=excluded.updated_at`,
|
||||
deployment.ActivePort, deployment.ContainerName, deployment.ImageDigest, deployment.ContainerID, transactionID, formatTime(now))
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("write Node SSR container deployment: %w", err)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE transactions SET state=?, version=version+1, updated_at=? WHERE id=? AND version=? AND state=?`, StateCommitted, formatTime(now), transactionID, record.Version, StateDraining)
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("commit Node SSR transaction state: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil || rows != 1 {
|
||||
return Transaction{}, errors.New("Node SSR transaction changed concurrently")
|
||||
}
|
||||
if err := insertEvent(ctx, tx, transactionID, "", "TRANSACTION_STATE_CHANGED", record.State, StateCommitted, message, now); err != nil {
|
||||
return Transaction{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Transaction{}, fmt.Errorf("commit Node SSR container deployment: %w", err)
|
||||
}
|
||||
record.State = StateCommitted
|
||||
record.Version++
|
||||
record.UpdatedAt = now
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func validateNodeSsrContainerDeployment(deployment NodeSsrContainerDeployment) error {
|
||||
if deployment.ActivePort != 18910 && deployment.ActivePort != 28910 {
|
||||
return fmt.Errorf("Node SSR container deployment port must be 18910 or 28910: %d", deployment.ActivePort)
|
||||
}
|
||||
fields := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "container name", value: deployment.ContainerName},
|
||||
{name: "image digest", value: deployment.ImageDigest},
|
||||
{name: "container ID", value: deployment.ContainerID},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.value == "" || strings.TrimSpace(field.value) != field.value {
|
||||
return fmt.Errorf("exact Node SSR container %s is required", field.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanNodeSsrContainerDeployment(row rowScanner) (NodeSsrContainerDeployment, error) {
|
||||
var deployment NodeSsrContainerDeployment
|
||||
var updatedAt string
|
||||
if err := row.Scan(&deployment.ActivePort, &deployment.ContainerName, &deployment.ImageDigest, &deployment.ContainerID, &deployment.TransactionID, &updatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NodeSsrContainerDeployment{}, ErrNotFound
|
||||
}
|
||||
return NodeSsrContainerDeployment{}, fmt.Errorf("scan Node SSR container deployment: %w", err)
|
||||
}
|
||||
parsed, err := parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return NodeSsrContainerDeployment{}, fmt.Errorf("parse Node SSR deployment update time: %w", err)
|
||||
}
|
||||
deployment.UpdatedAt = parsed
|
||||
return deployment, nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/ncruces/go-sqlite3/driver"
|
||||
)
|
||||
|
||||
const schemaVersion = 2
|
||||
const schemaVersion = 3
|
||||
|
||||
// schemaV1 SQLite 数据库的首版 schema,定义事务、步骤与事件三张核心表。
|
||||
//
|
||||
@@ -95,6 +95,21 @@ CREATE TABLE backend_container_deployment (
|
||||
PRAGMA user_version = 2;
|
||||
`
|
||||
|
||||
// schemaV3 新增 Node SSR 容器部署单例表。
|
||||
const schemaV3 = `
|
||||
CREATE TABLE node_ssr_container_deployment (
|
||||
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
||||
active_port INTEGER NOT NULL CHECK (active_port IN (18910, 28910)),
|
||||
container_name TEXT NOT NULL,
|
||||
image_digest TEXT NOT NULL,
|
||||
container_id TEXT NOT NULL,
|
||||
transaction_id TEXT NOT NULL REFERENCES transactions(id),
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
PRAGMA user_version = 3;
|
||||
`
|
||||
|
||||
// Store 服务端 SQLite 事务记录。一个进程只应创建一个 Store。
|
||||
//
|
||||
// 它封装了底层 *sql.DB,并把单连接访问(MaxOpenConns/MaxIdleConns 均为 1)作为
|
||||
@@ -222,6 +237,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
|
||||
script = schemaV1
|
||||
case 2:
|
||||
script = schemaV2
|
||||
case 3:
|
||||
script = schemaV3
|
||||
default:
|
||||
return fmt.Errorf("sqlite migration script is missing for version %d", nextVersion)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -67,87 +68,13 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr
|
||||
}
|
||||
switch arguments[0] {
|
||||
case "update":
|
||||
request, err := parseUpdateArgs(arguments[1:], stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var progress func(daemonapi.Response)
|
||||
if !request.quite {
|
||||
progress = func(event daemonapi.Response) {
|
||||
writeUpdateProgress(stdout, event)
|
||||
}
|
||||
}
|
||||
var response daemonapi.Response
|
||||
if request.inputType == daemonapi.InputTypeContainerImage {
|
||||
response, err = daemonclient.UpdateContainerImage(ctx, runtimepaths.Socket, request.service, request.imageReference, request.startLog, progress)
|
||||
} else {
|
||||
response, err = daemonclient.Update(ctx, runtimepaths.Socket, request.service, request.inputType, request.file, progress)
|
||||
}
|
||||
if err != nil {
|
||||
if response.TransactionID != "" {
|
||||
fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "ymsctl update failed:", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !request.quite {
|
||||
fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State)
|
||||
}
|
||||
return 0
|
||||
return runUpdate(ctx, arguments[1:], stdout, stderr)
|
||||
case "list":
|
||||
if err := runList(ctx, arguments[1:], stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, "ymsctl list failed:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return runListCommand(ctx, arguments[1:], stdout, stderr)
|
||||
case "restart":
|
||||
request, err := parseRestartArgs(arguments[1:], stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var progress func(daemonapi.Response)
|
||||
if !request.quite {
|
||||
progress = func(event daemonapi.Response) {
|
||||
writeUpdateProgress(stdout, event)
|
||||
}
|
||||
}
|
||||
response, err := daemonclient.Restart(ctx, runtimepaths.Socket, request.service, progress)
|
||||
if err != nil {
|
||||
if response.TransactionID != "" {
|
||||
fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "ymsctl restart failed:", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !request.quite {
|
||||
fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State)
|
||||
}
|
||||
return 0
|
||||
return runRestart(ctx, arguments[1:], stdout, stderr)
|
||||
case "status", "doctor", "reconcile":
|
||||
request, err := parseDiagnosisArgs(arguments[0], arguments[1:], stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var diagnosis daemonapi.Diagnosis
|
||||
switch arguments[0] {
|
||||
case "status":
|
||||
diagnosis, err = daemonclient.Status(ctx, runtimepaths.Socket, request.service)
|
||||
case "doctor":
|
||||
diagnosis, err = daemonclient.Doctor(ctx, runtimepaths.Socket, request.service)
|
||||
case "reconcile":
|
||||
diagnosis, err = daemonclient.Reconcile(ctx, runtimepaths.Socket, request.service, request.apply)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "ymsctl %s failed: %v\n", arguments[0], err)
|
||||
return 1
|
||||
}
|
||||
writeDiagnosis(stdout, arguments[0], diagnosis)
|
||||
return 0
|
||||
return runDiagnosis(ctx, arguments[0], arguments[1:], stdout, stderr)
|
||||
case "help", "-h", "--help":
|
||||
writeUsage(stdout)
|
||||
return 0
|
||||
@@ -158,6 +85,96 @@ func run(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Wr
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Writer) int {
|
||||
request, err := parseUpdateArgs(arguments, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var progress func(daemonapi.Response)
|
||||
if !request.quite {
|
||||
progress = func(event daemonapi.Response) {
|
||||
writeUpdateProgress(stdout, event)
|
||||
}
|
||||
}
|
||||
var response daemonapi.Response
|
||||
if request.inputType == daemonapi.InputTypeContainerImage {
|
||||
response, err = daemonclient.UpdateContainerImage(ctx, runtimepaths.Socket, request.service, request.imageReference, request.startLog, progress)
|
||||
} else {
|
||||
response, err = daemonclient.Update(ctx, runtimepaths.Socket, request.service, request.inputType, request.file, progress)
|
||||
}
|
||||
if err != nil {
|
||||
if response.TransactionID != "" {
|
||||
fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "ymsctl update failed:", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !request.quite {
|
||||
fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runListCommand(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if err := runList(ctx, arguments, stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, "ymsctl list failed:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runRestart(ctx context.Context, arguments []string, stdout io.Writer, stderr io.Writer) int {
|
||||
request, err := parseRestartArgs(arguments, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var progress func(daemonapi.Response)
|
||||
if !request.quite {
|
||||
progress = func(event daemonapi.Response) {
|
||||
writeUpdateProgress(stdout, event)
|
||||
}
|
||||
}
|
||||
response, err := daemonclient.Restart(ctx, runtimepaths.Socket, request.service, progress)
|
||||
if err != nil {
|
||||
if response.TransactionID != "" {
|
||||
fmt.Fprintf(stderr, "transaction=%s state=%s error=%v\n", response.TransactionID, response.State, err)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "ymsctl restart failed:", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !request.quite {
|
||||
fmt.Fprintf(stdout, "transaction=%s state=%s\n", response.TransactionID, response.State)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runDiagnosis(ctx context.Context, command string, arguments []string, stdout io.Writer, stderr io.Writer) int {
|
||||
request, err := parseDiagnosisArgs(command, arguments, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
var diagnosis daemonapi.Diagnosis
|
||||
switch command {
|
||||
case "status":
|
||||
diagnosis, err = daemonclient.Status(ctx, runtimepaths.Socket, request.service)
|
||||
case "doctor":
|
||||
diagnosis, err = daemonclient.Doctor(ctx, runtimepaths.Socket, request.service)
|
||||
case "reconcile":
|
||||
diagnosis, err = daemonclient.Reconcile(ctx, runtimepaths.Socket, request.service, request.apply)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "ymsctl %s failed: %v\n", command, err)
|
||||
return 1
|
||||
}
|
||||
writeDiagnosis(stdout, command, diagnosis)
|
||||
return 0
|
||||
}
|
||||
|
||||
// updateArguments 保存 update 子命令解析后的参数。
|
||||
type updateArguments struct {
|
||||
// service 目标服务名,当前仅接受 backend。
|
||||
@@ -326,34 +343,9 @@ func runServe(ctx context.Context) (result error) {
|
||||
return err
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
var updater *backendupdate.Updater
|
||||
var diagnoser *backendstatus.Diagnoser
|
||||
switch config.Backend.Type {
|
||||
case deploymentconfig.BackendTypeNative:
|
||||
releaseStore, err := filestore.New(config.Backend.ReleaseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
units, err := systemd.NewSystemctl(config.Backend.SystemctlPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updater, err = backendupdate.New(config, runtimepaths.WorkRoot, store, coordinator, releaseStore, units, gateway, httpClient, logger)
|
||||
if err == nil {
|
||||
diagnoser, err = backendstatus.New(config, store, coordinator, nil, units, gateway)
|
||||
}
|
||||
case deploymentconfig.BackendTypeContainer:
|
||||
var engine *containerengine.MobyEngine
|
||||
engine, err = containerengine.NewMobyEngine()
|
||||
if err == nil {
|
||||
defer func() { result = errors.Join(result, engine.Close()) }()
|
||||
updater, err = backendupdate.NewContainer(config, runtimepaths.WorkRoot, store, coordinator, engine, gateway, httpClient, logger)
|
||||
if err == nil {
|
||||
diagnoser, err = backendstatus.New(config, store, coordinator, engine, nil, gateway)
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported backend.type %q", config.Backend.Type)
|
||||
updater, diagnoser, closeRuntime, err := buildRuntime(config, store, coordinator, gateway, httpClient, logger)
|
||||
if closeRuntime != nil {
|
||||
defer func() { result = errors.Join(result, closeRuntime()) }()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -365,6 +357,50 @@ func runServe(ctx context.Context) (result error) {
|
||||
return server.Serve(ctx)
|
||||
}
|
||||
|
||||
type runtimeCloser func() error
|
||||
|
||||
func buildRuntime(config deploymentconfig.Config, store *transaction.Store, coordinator *transaction.Coordinator, gateway *hostnginx.Controller, httpClient *http.Client, logger *slog.Logger) (*backendupdate.Updater, *backendstatus.Diagnoser, runtimeCloser, error) {
|
||||
switch config.Backend.Type {
|
||||
case deploymentconfig.BackendTypeNative:
|
||||
return buildNativeRuntime(config, store, coordinator, gateway, httpClient, logger)
|
||||
case deploymentconfig.BackendTypeContainer:
|
||||
return buildContainerRuntime(config, store, coordinator, gateway, httpClient, logger)
|
||||
default:
|
||||
return nil, nil, nil, fmt.Errorf("unsupported backend.type %q", config.Backend.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func buildNativeRuntime(config deploymentconfig.Config, store *transaction.Store, coordinator *transaction.Coordinator, gateway *hostnginx.Controller, httpClient *http.Client, logger *slog.Logger) (*backendupdate.Updater, *backendstatus.Diagnoser, runtimeCloser, error) {
|
||||
releaseStore, err := filestore.New(config.Backend.ReleaseDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
units, err := systemd.NewSystemctl(config.Backend.SystemctlPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
updater, err := backendupdate.New(config, runtimepaths.WorkRoot, store, coordinator, releaseStore, units, gateway, httpClient, logger)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
diagnoser, err := backendstatus.New(config, store, coordinator, nil, units, gateway)
|
||||
return updater, diagnoser, nil, err
|
||||
}
|
||||
|
||||
func buildContainerRuntime(config deploymentconfig.Config, store *transaction.Store, coordinator *transaction.Coordinator, gateway *hostnginx.Controller, httpClient *http.Client, logger *slog.Logger) (*backendupdate.Updater, *backendstatus.Diagnoser, runtimeCloser, error) {
|
||||
engine, err := containerengine.NewMobyEngine()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
closeEngine := runtimeCloser(engine.Close)
|
||||
updater, err := backendupdate.NewContainer(config, runtimepaths.WorkRoot, store, coordinator, engine, gateway, httpClient, logger)
|
||||
if err != nil {
|
||||
return nil, nil, closeEngine, err
|
||||
}
|
||||
diagnoser, err := backendstatus.New(config, store, coordinator, engine, nil, gateway)
|
||||
return updater, diagnoser, closeEngine, err
|
||||
}
|
||||
|
||||
// writeUsage 向 output 写出命令行客户端各子命令的用法说明。
|
||||
func writeUsage(output io.Writer) {
|
||||
fmt.Fprintln(output, "usage:")
|
||||
@@ -392,33 +428,15 @@ func writeUpdateProgress(output io.Writer, event daemonapi.Response) {
|
||||
// status 只展示需要关注的漂移与可修复项,doctor 展示全部诊断项,
|
||||
// reconcile 只展示可自动修复项作为修复计划。
|
||||
func writeDiagnosis(output io.Writer, command string, diagnosis daemonapi.Diagnosis) {
|
||||
state := "HEALTHY"
|
||||
if !diagnosis.Healthy {
|
||||
state = "DRIFT"
|
||||
} else {
|
||||
for _, item := range diagnosis.Items {
|
||||
if item.Level == daemonapi.DiagnosisLevelFixable {
|
||||
state = "REPAIRABLE"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(output, "service=%s type=%s status=%s\n", diagnosis.Service, diagnosis.Type, state)
|
||||
fmt.Fprintf(output, "service=%s type=%s status=%s\n", diagnosis.Service, diagnosis.Type, diagnosisState(diagnosis))
|
||||
if diagnosis.RepairApplied {
|
||||
fmt.Fprintf(output, "APPLIED 对账修复已执行 transaction=%s\n", diagnosis.RepairTransactionID)
|
||||
fmt.Fprintln(output, "RESULT 现场已重新核对")
|
||||
}
|
||||
shown := 0
|
||||
for _, item := range diagnosis.Items {
|
||||
switch command {
|
||||
case "status":
|
||||
if item.Level == daemonapi.DiagnosisLevelOK {
|
||||
continue
|
||||
}
|
||||
case "reconcile":
|
||||
if item.Level != daemonapi.DiagnosisLevelFixable {
|
||||
continue
|
||||
}
|
||||
if !showDiagnosisItem(command, item.Level) {
|
||||
continue
|
||||
}
|
||||
shown++
|
||||
fmt.Fprintf(output, "%-8s %s\n", strings.ToUpper(item.Level), item.Message)
|
||||
@@ -433,6 +451,29 @@ func writeDiagnosis(output io.Writer, command string, diagnosis daemonapi.Diagno
|
||||
}
|
||||
}
|
||||
|
||||
func diagnosisState(diagnosis daemonapi.Diagnosis) string {
|
||||
if !diagnosis.Healthy {
|
||||
return "DRIFT"
|
||||
}
|
||||
for _, item := range diagnosis.Items {
|
||||
if item.Level == daemonapi.DiagnosisLevelFixable {
|
||||
return "REPAIRABLE"
|
||||
}
|
||||
}
|
||||
return "HEALTHY"
|
||||
}
|
||||
|
||||
func showDiagnosisItem(command string, level string) bool {
|
||||
switch command {
|
||||
case "status":
|
||||
return level != daemonapi.DiagnosisLevelOK
|
||||
case "reconcile":
|
||||
return level == daemonapi.DiagnosisLevelFixable
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// runList 执行 list 子命令:解析过滤参数,打开事务存储读取最近事务,
|
||||
// 按是否指定 --json 决定以稳定 JSON 或制表符分隔的表格形式输出到 stdout。
|
||||
// 它支持 --limit、--service、--state、--json 四个选项,返回查询或输出阶段的错误。
|
||||
|
||||
@@ -48,6 +48,21 @@ 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"
|
||||
|
||||
# =============================================================================
|
||||
# Node SSR:容器双槽,宿主 18910/28910,Nginx 逻辑入口 8910
|
||||
# =============================================================================
|
||||
[nodeSsr]
|
||||
type = "container"
|
||||
# stop_grace_seconds = 30
|
||||
|
||||
[nodeSsr.slot.18910]
|
||||
container_name = "node-ssr-18910"
|
||||
health_endpoint = "http://127.0.0.1:18910/health"
|
||||
|
||||
[nodeSsr.slot.28910]
|
||||
container_name = "node-ssr-28910"
|
||||
health_endpoint = "http://127.0.0.1:28910/health"
|
||||
|
||||
# =============================================================================
|
||||
# 方式二:容器后端(切换到容器时,取消注释本块,并注释掉上面的方式一)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user