Skip to content

Validate runtime update controls before persisting - #514

Open
GOLDKUN wants to merge 2 commits into
chaitin:mainfrom
GOLDKUN:fix/validate-runtime-updates-before-persisting
Open

Validate runtime update controls before persisting#514
GOLDKUN wants to merge 2 commits into
chaitin:mainfrom
GOLDKUN:fix/validate-runtime-updates-before-persisting

Conversation

@GOLDKUN

@GOLDKUN GOLDKUN commented Sep 2, 2026

Copy link
Copy Markdown

问题

UpdateConfigUpdateSecret 在完成文件/数据库写入后,才检查 On-demand 服务是否支持 restart。

影响

客户端收到失败响应时,配置或 Secret 其实已经被修改,造成 API 语义与持久化状态不一致;自动化重试可能进一步覆盖数据并造成审计误判。

修复内容

  • 将运行时控制能力检查移动到所有持久化操作之前。
  • On-demand 实例请求 restart 时立即返回不支持错误。
  • 确保失败路径不写入配置文件、Secret 或数据库。
  • 增加配置和 Secret 的回归测试。

验证

  • go test ./internal/supervisor -run TestRuntimeControlRejectionPrecedesInstanceMutation 通过。

@monkeyscan

monkeyscan Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Title: Validate runtime update controls before persisting

Commit: ed19086

本次变更将 supervisor.go 中 UpdateSecret 与 UpdateConfig 针对 on-demand 运行时模式的拒绝检查(rejectOnDemandRuntimeControl)从"实例变更之后"提前到"变更之前"。旧实现中,对 on-demand 服务传 restart=true 的更新请求会先持久化新的 secret/config(UpdateConfig 还会先写磁盘 config.json),随后才被拒绝并返回 ErrUnsupportedRuntimeControl,形成"报错但状态已被修改"的部分更新。新实现确保被拒绝的请求不产生任何实例变更,并新增 runtime_update_test.go 验证 store 中实例未被修改。该检查为只读(重新 GetService),仅作用于 restart=true 路径,对非 on-demand 服务与 restart=false 的普通更新行为不变,修复方向正确。唯一不足是新增测试仅断言了 store 中的 ConfigJSON/SecretJSON 未变,未覆盖 UpdateConfig 的磁盘 config.json 写入通道,存在回归保护缺口。

}
if string(got.ConfigJSON) != string(originalConfig) || string(got.SecretJSON) != string(originalSecret) {
t.Fatalf("rejected runtime control mutated instance: %+v", got)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增测试未验证被拒绝的 UpdateConfig 不会写入磁盘 config.json

本次修复的目标是保证运行时控制被拒绝时实例不发生任何变更。UpdateConfig 存在两条变更通道:磁盘上的 config.json(通过 writeInstanceConfig 写入实例工作目录)与 store 中的 ConfigJSON/ConfigSHA256。新测试仅断言 store 中 ConfigJSON 与 SecretJSON 未变,未断言磁盘 config.json 未被写入。旧代码中 writeInstanceConfig 在拒绝检查之前执行,被拒绝的请求会先污染磁盘文件;新代码虽然把拒绝检查提前到 writeInstanceConfig 之前,但测试没有锁住这一行为。若将来有人把拒绝检查重新移到 writeInstanceConfig 之后、UpsertInstance 之前,本测试仍会通过,而磁盘 config.json 会被静默改写,导致实例下次冷启动使用错误配置,回归风险未被该测试覆盖。

Problem code:

Changed code at internal/supervisor/runtime_update_test.go:35-41

Recommendation:
在测试断言中补充对磁盘 config.json 的检查:读取 sup.InstanceWorkdir("instance")/config.json,若文件存在则其内容必须仍等于 originalConfig(不存在也可接受,说明 writeInstanceConfig 未被调用)。这样与 store 断言共同覆盖两条变更通道。

Suggested diff:

diff --git a/internal/supervisor/runtime_update_test.go b/internal/supervisor/runtime_update_test.go
--- a/internal/supervisor/runtime_update_test.go
+++ b/internal/supervisor/runtime_update_test.go
@@ -3,6 +3,8 @@
 import (
 	"context"
 	"errors"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"octobus/internal/domain"
@@ -36,5 +38,11 @@
 	if string(got.ConfigJSON) != string(originalConfig) || string(got.SecretJSON) != string(originalSecret) {
 		t.Fatalf("rejected runtime control mutated instance: %+v", got)
 	}
+	diskConfig, err := os.ReadFile(filepath.Join(sup.InstanceWorkdir("instance"), "config.json"))
+	if err == nil {
+		if string(diskConfig) != string(originalConfig) {
+			t.Fatalf("rejected runtime control wrote disk config: %s", diskConfig)
+		}
+	} else if !os.IsNotExist(err) {
+		t.Fatalf("read disk config: %v", err)
+	}
 }

@monkeyscan

monkeyscan Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Title: Validate runtime update controls before persisting

Commit: b34a50e

本次改动仅涉及 internal/supervisor/supervisor.go:

  1. UpdateConfig 在写新配置前先读取磁盘旧 config.json;当 Store.UpsertInstance 失败时把磁盘配置回滚为旧内容(或删除本次新建文件),避免磁盘与存储层不一致。
  2. writeInstanceConfig 改为"临时文件 + chmod 0600 + 写入 + fsync + rename"的原子写,避免子进程读到半个配置。
    核查了 UpdateConfig/startWithAttempt 的集成路径(启动子进程前都会从 DB 重写 config.json,DB 为权威、磁盘为启动快照),确认单线程语义自洽。发现两个问题:
  • 回滚逻辑无 per-instance 串行化/版本校验,并发更新同一实例时失败方的回滚可能把其他并发成功提交的 config.json 覆盖回陈旧值,导致磁盘与 DB 不一致(中)。
  • writeInstanceConfig 原子写完成后未对父目录 fsync,断电/崩溃后 rename 目录项可能未持久化,持久性保证不完整(低)。

}
if restoreErr != nil {
return domain.Instance{}, fmt.Errorf("persist config: %w; restore previous config: %v", err, restoreErr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateConfig 失败回滚在并发更新同一实例时可能把磁盘配置覆盖回陈旧值,造成与 DB 不一致

新增回滚逻辑在 UpsertInstance 失败时把磁盘 config.json 恢复为函数开头读到的 previousConfig(或直接删除)。但 UpdateConfig 全程没有 per-instance 锁或版本校验(supervisor 内 s.mu 只保护 procs/generations,配置更新并不持锁)。当同一 instance 的两个 UpdateConfig 并发执行、其中一个 UpsertInstance 失败时:成功方已把 config.json 写成新配置并提交 DB;失败方回滚时依据自己早先读到的陈旧 previousConfig,会把 config.json 覆盖回旧内容(或 os.Remove 掉成功方刚写入的文件),导致磁盘 config.json 与 DB ConfigJSON 不一致。在下次 startWithAttempt 用 DB 重写 config.json 之前,以 config.json 为 --config 启动的子进程可能按旧配置运行。startWithAttempt 每次启动前都会从 DB 重写 config.json,说明 DB 才是权威、磁盘只是启动快照;基于磁盘旧值回滚而不是基于 DB 值,在并发/历史不一致场景会把磁盘拉向错误方向。

Problem code:

Changed code at internal/supervisor/supervisor.go:200-211

Recommendation:
对同一 instance 的配置更新加串行化(例如 per-instance mutex,或基于 generation/版本号的乐观校验),使回滚只可能撤销自己这次的写入,而不会覆盖其他并发请求已成功提交的配置;或在回滚前重新读取并确认 config.json 仍等于本次写入内容后再执行恢复。

if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, filepath.Join(workdir, "config.json"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeInstanceConfig 原子写缺少 rename 后的父目录 fsync,断电/崩溃后配置可能回退

新的原子写实现已对临时文件调用 tmp.Sync() 并在 rename 后返回,但未对父目录(workdir)执行 fsync。rename 对应的目录项是否落盘不受保证:系统断电/崩溃后目录项可能未持久化,config.json 可能回退为旧内容或缺失,而调用方(UpdateConfig/CreateInstance)随后已把新配置写入 DB,从而出现磁盘 config.json 与 DB ConfigJSON 不一致。该改动特意加了 tmp.Sync(),说明作者关注持久性,但目录 fsync 缺失使持久性保证不完整。

Problem code:

Changed code at internal/supervisor/supervisor.go:654-675

Recommendation:
在 os.Rename 成功后打开 workdir 目录并调用 Sync(),以持久化目录项、确保原子替换在崩溃恢复后仍然生效;若文件系统不支持目录 Sync(如某些平台),可忽略对应错误并注释说明。

@GOLDKUN

GOLDKUN commented Sep 2, 2026

Copy link
Copy Markdown
Author

Follow-up hardening after review:

  • Config files are now written through a synced temporary file and atomic rename.
  • If instance persistence fails after a config update, the previous file is restored (or the new file is removed when none existed).
  • The original runtime-control validation remains before all persistence operations.

Verification: go test ./internal/supervisor passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant