Compare commits

...

25 Commits

Author SHA1 Message Date
Reese eac689cf4b split container update preparation 2026-08-22 16:09:16 +08:00
Reese afa371b250 split container slot reconciliation 2026-08-22 16:08:07 +08:00
Reese a37ee9d09d split daemon request and runtime setup 2026-08-22 16:07:12 +08:00
Reese b163b48ae3 refactor container image resolution 2026-08-22 16:04:32 +08:00
Reese 0102fa6ce9 refactor config and nginx parsers 2026-08-22 16:04:32 +08:00
Reese a06771b708 docs: record executor complexity cleanup 2026-08-22 15:59:29 +08:00
Reese 67214dd858 refactor: split container executor validation states 2026-08-22 15:59:20 +08:00
Reese 68d067d744 docs: record cognitive complexity cleanup 2026-08-22 15:58:37 +08:00
Reese 076167d3ad refactor: split CLI diagnosis and status rendering 2026-08-22 15:58:37 +08:00
Reese 3f13130b8a refactor: split container switch and commit steps 2026-08-22 15:57:03 +08:00
Reese a81f81e347 refactor: split deployment configuration validation 2026-08-22 15:57:03 +08:00
Reese 54567e243c docs: record sonar cleanup 2026-08-22 15:53:52 +08:00
Reese 99082fd8c3 refactor: simplify optional container path validation 2026-08-22 15:53:31 +08:00
Reese d2070c9d5b refactor: simplify Node SSR parsing and validation 2026-08-22 15:53:31 +08:00
Reese 56e104ccf0 fix: name Node SSR upstream ssr 2026-08-22 15:50:27 +08:00
Reese 5a9a070160 docs: record Node SSR executor compatibility 2026-08-22 15:49:07 +08:00
Reese 40e314744a feat: extend container executor for Node SSR 2026-08-22 15:48:57 +08:00
Reese 13da865566 docs: record Node SSR transaction foundation 2026-08-22 15:46:07 +08:00
Reese ed80e2b656 feat: add Node SSR deployment transaction record 2026-08-22 15:45:57 +08:00
Reese 0f3e16ea41 chore: bump go to 1.26.6 2026-08-22 15:44:20 +08:00
Reese 14660657bd docs: record Node SSR rollout compatibility 2026-08-22 15:43:52 +08:00
Reese 3f6b1cba57 fix: keep Node SSR config optional during rollout 2026-08-22 15:43:44 +08:00
Reese 6966bd19b2 docs: record Node SSR implementation progress 2026-08-22 15:43:21 +08:00
Reese a909ecf245 feat: add Node SSR Nginx upstream switching 2026-08-22 15:43:07 +08:00
Reese 83a1f05f1a feat: add node SSR dual-slot deployment config 2026-08-22 15:41:41 +08:00
14 changed files with 1188 additions and 428 deletions
+68
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
module yms-daemon
go 1.26.5
go 1.26.6
require (
github.com/containerd/errdefs v1.0.0
+116 -61
View File
@@ -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 构造拉取并校验后端镜像步骤的持久化意图。
+5 -3
View File
@@ -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
View File
@@ -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 为触发补偿的原始错误,会与补偿过程中的错误
// 合并后返回。
+86 -71
View File
@@ -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)
+170 -41
View File
@@ -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 {
+22
View File
@@ -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 验证合法原生配置能被完整加载,
+162
View File
@@ -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
}
+30
View File
@@ -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
}
+18 -1
View File
@@ -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)
}
+168 -127
View File
@@ -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 四个选项,返回查询或输出阶段的错误。
+15
View File
@@ -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/28910Nginx 逻辑入口 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"
# =============================================================================
# 方式二:容器后端(切换到容器时,取消注释本块,并注释掉上面的方式一)
# =============================================================================