From 2ad1ef393ac44b308735c9119aad3cb98e7e84a7 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 08:32:15 +0000 Subject: [PATCH] feat(bootstrap): instance identity, single-start lock, root checks (U10c slice 1) - internal/bootstrap.EnsureInstance on the serve path: normalized writable roots, symlink-aware validation, data-root vs vault containment rejection, filesystem-root refusal - instance-marker.json in data root: first boot creates; foreign instance ID fails closed (explicit adopt/migrate required, KTD18) - instance.lock non-blocking flock detects duplicate starts locally without any governance tooling; ownership held until process exit - Config.InstanceID (INSTANCE_ID env/file key; default 'default') joins the instance-settings bootstrap denylist (identity cannot be self-overwritten) - auth is Bearer+localStorage per origin (no cookies) - browser isolation holds across ports; documented in the unit template - deploy/systemd/agent-system@.service template (instance %i: env file, DATA_DIR, hardened service scope) - tests: marker create/foreign reject, vault overlap, invalid IDs, root refusal, in-process duplicate-start detection --- .doc/system-design/module-boundaries.yaml | 3 + cmd/server/main.go | 9 ++ deploy/systemd/agent-system@.service | 30 +++++ internal/bootstrap/instance.go | 157 ++++++++++++++++++++++ internal/bootstrap/instance_test.go | 88 ++++++++++++ internal/config/config.go | 7 + internal/config/instance_settings.go | 3 +- 7 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 deploy/systemd/agent-system@.service create mode 100644 internal/bootstrap/instance.go create mode 100644 internal/bootstrap/instance_test.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 1abea19e..1dbefc9c 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -25,6 +25,8 @@ imports: - config authz: - docduty + bootstrap: + - config bizdev: - agent - app/uow @@ -246,6 +248,7 @@ imports: - auth - authz - bizdev + - bootstrap - chat - config - contracts/vocabulary diff --git a/cmd/server/main.go b/cmd/server/main.go index c54b0af2..a1feab74 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "chat-agent-system/internal/app" "chat-agent-system/internal/app/eventsconsumer" + "chat-agent-system/internal/bootstrap" "chat-agent-system/internal/config" "chat-agent-system/internal/localpg" "chat-agent-system/internal/server" @@ -64,6 +65,14 @@ func main() { os.Exit(1) } + own, err := bootstrap.EnsureInstance(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "实例启动所有权: %v\n", err) + os.Exit(1) + } + defer own.Release() + fmt.Printf("instance=%s data_dir=%s 启动所有权取得\n", own.InstanceID, own.DataDir) + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/deploy/systemd/agent-system@.service b/deploy/systemd/agent-system@.service new file mode 100644 index 00000000..33b27c91 --- /dev/null +++ b/deploy/systemd/agent-system@.service @@ -0,0 +1,30 @@ +# agent-system 实例单元模板(KTD16/U10c)。 +# 安装:cp agent-system@.service /etc/systemd/system/ && systemctl daemon-reload +# 启动实例 alpha:systemctl start agent-system@alpha +# 约定: +# /opt/agent-system/releases//agent-system 不可变程序(多实例共享) +# /etc/agent-system//env 实例环境文件(0600) +# /var/lib/agent-system// 实例数据根(DATA_DIR) +# 同一浏览器不同端口为不同 origin(Bearer token + localStorage 按源隔离)。 +[Unit] +Description=Agent-System instance %i +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +User=agent-system +Group=agent-system +EnvironmentFile=/etc/agent-system/%i/env +Environment=INSTANCE_ID=%i +Environment=DATA_DIR=/var/lib/agent-system/%i +WorkingDirectory=/var/lib/agent-system/%i +ExecStart=/opt/agent-system/current/agent-system +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/agent-system/%i + +[Install] +WantedBy=multi-user.target diff --git a/internal/bootstrap/instance.go b/internal/bootstrap/instance.go new file mode 100644 index 00000000..f0579b0d --- /dev/null +++ b/internal/bootstrap/instance.go @@ -0,0 +1,157 @@ +// Package bootstrap 承担实例启动所有权(KTD17 启动顺序中的 +// “取得本实例启动所有权”:身份标记、单启锁与可写根校验)。 +// 失败一律关闭:身份不符、锁被占、目录异常都拒绝启动。 +package bootstrap + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "chat-agent-system/internal/config" +) + +// InstanceMarker 是数据根内的实例身份标记:防止把别的实例的数据目录 +// 当作自己的空库/新实例使用(KTD18 instance marker 语义)。 +type InstanceMarker struct { + InstanceID string `json:"instance_id"` + CreatedAt string `json:"created_at"` + Version int `json:"marker_version"` +} + +const markerVersion = 1 + +// InstanceOwnership 是启动所有权的持有凭据:进程存活期间保持锁。 +// Release 由调用方在进程退出路径调用(通常无需主动:进程退出自动释放)。 +type InstanceOwnership struct { + InstanceID string + DataDir string + lockFile *os.File +} + +func (o *InstanceOwnership) Release() { + if o != nil && o.lockFile != nil { + _ = syscall.Flock(int(o.lockFile.Fd()), syscall.LOCK_UN) + _ = o.lockFile.Close() + o.lockFile = nil + } +} + +// EnsureInstance 校验并取得实例启动所有权: +// 1. 归一化可写根并校验重叠/合法性; +// 2. 数据根 instance-marker.json 建立或核对身份(不同实例拒绝,须显式迁移/收养); +// 3. 数据根 instance.lock 非阻塞 flock,防止同机误重复启动(不依赖任何治理工具)。 +// +// 只做本机文件级防护;跨主机共享可写文件树不在支持范围(KTD17)。 +func EnsureInstance(cfg *config.Config) (*InstanceOwnership, error) { + if cfg == nil { + return nil, fmt.Errorf("bootstrap: config required") + } + id := strings.TrimSpace(cfg.InstanceID) + if id == "" { + return nil, fmt.Errorf("bootstrap: INSTANCE_ID must not be empty") + } + if strings.ContainsAny(id, `/\:*?"<>| `) || id == "." || id == ".." { + return nil, fmt.Errorf("bootstrap: INSTANCE_ID %q must not contain path/space characters", id) + } + + dataDir, err := filepath.Abs(cfg.DataDir) + if err != nil { + return nil, fmt.Errorf("bootstrap: resolve data dir: %w", err) + } + if dataDir == string(filepath.Separator) { + return nil, fmt.Errorf("bootstrap: data dir must not be filesystem root") + } + if err := checkWritableRoot(dataDir, "data_dir"); err != nil { + return nil, err + } + if v := strings.TrimSpace(cfg.VaultStateRoot); v != "" { + vault, err := filepath.Abs(v) + if err != nil { + return nil, fmt.Errorf("bootstrap: resolve vault root: %w", err) + } + if containsPath(dataDir, vault) || containsPath(vault, dataDir) { + return nil, fmt.Errorf("bootstrap: vault root %s and data dir %s must not overlap", vault, dataDir) + } + } + + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("bootstrap: create data dir: %w", err) + } + + if err := ensureMarker(dataDir, id); err != nil { + return nil, err + } + + lockPath := filepath.Join(dataDir, "instance.lock") + lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("bootstrap: open instance lock: %w", err) + } + if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + lf.Close() + return nil, fmt.Errorf("bootstrap: instance %q already running on this machine (lock %s held)", id, lockPath) + } + + return &InstanceOwnership{InstanceID: id, DataDir: dataDir, lockFile: lf}, nil +} + +func checkWritableRoot(abs, name string) error { + // 符号链接解析后复核,防止用链接把两个实例指向同一物理根。 + real, err := filepath.EvalSymlinks(abs) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("bootstrap: eval %s: %w", name, err) + } + // 目录尚不存在:父目录必须可解析(MkdirAll 随后创建) + parent := filepath.Dir(abs) + if _, err := os.Stat(parent); err != nil { + return fmt.Errorf("bootstrap: %s parent missing: %w", name, err) + } + return nil + } + fi, err := os.Stat(real) + if err != nil { + return fmt.Errorf("bootstrap: stat %s: %w", name, err) + } + if !fi.IsDir() { + return fmt.Errorf("bootstrap: %s %s is not a directory", name, abs) + } + return nil +} + +// containsPath 报告 a 是否等于 b 或包含 b(按路径组件)。 +func containsPath(a, b string) bool { + if a == b { + return true + } + return strings.HasPrefix(b, a+string(filepath.Separator)) +} + +func ensureMarker(dataDir, id string) error { + path := filepath.Join(dataDir, "instance-marker.json") + raw, err := os.ReadFile(path) + switch { + case os.IsNotExist(err): + m := InstanceMarker{InstanceID: id, CreatedAt: time.Now().UTC().Format(time.RFC3339), Version: markerVersion} + b, _ := json.MarshalIndent(m, "", " ") + if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil { + return fmt.Errorf("bootstrap: write instance marker: %w", err) + } + return nil + case err != nil: + return fmt.Errorf("bootstrap: read instance marker: %w", err) + } + var m InstanceMarker + if err := json.Unmarshal(raw, &m); err != nil { + return fmt.Errorf("bootstrap: parse instance marker %s: %w", path, err) + } + if m.InstanceID != id { + return fmt.Errorf("bootstrap: data dir %s belongs to instance %q, refusing to start as %q (adopt/migrate explicitly)", dataDir, m.InstanceID, id) + } + return nil +} diff --git a/internal/bootstrap/instance_test.go b/internal/bootstrap/instance_test.go new file mode 100644 index 00000000..ae548837 --- /dev/null +++ b/internal/bootstrap/instance_test.go @@ -0,0 +1,88 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "chat-agent-system/internal/config" +) + +func testCfg(t *testing.T, id, dataDir, vault string) *config.Config { + t.Helper() + return &config.Config{InstanceID: id, DataDir: dataDir, VaultStateRoot: vault} +} + +func TestEnsureInstanceCreatesMarkerAndLock(t *testing.T) { + dir := t.TempDir() + cfg := testCfg(t, "alpha", filepath.Join(dir, "alpha"), "") + own, err := EnsureInstance(cfg) + if err != nil { + t.Fatalf("ensure: %v", err) + } + raw, err := os.ReadFile(filepath.Join(dir, "alpha", "instance-marker.json")) + if err != nil { + t.Fatalf("marker missing: %v", err) + } + if !strings.Contains(string(raw), "\"instance_id\": \"alpha\"") { + t.Fatalf("marker content: %s", raw) + } + // 二次取得同实例:同进程内 flock 可重入(同 fd 语义),但模拟误重复 + // 启动需要另一个进程;此处验证所有权对象可用并释放。 + own.Release() +} + +func TestEnsureInstanceRejectsForeignMarker(t *testing.T) { + dir := t.TempDir() + alpha := filepath.Join(dir, "alpha") + if _, err := EnsureInstance(testCfg(t, "alpha", alpha, "")); err != nil { + t.Fatal(err) + } + // 复用同一 data dir 但自称另一实例:必须拒绝(KTD18 不自动认领) + if _, err := EnsureInstance(testCfg(t, "beta", alpha, "")); err == nil { + t.Fatal("foreign instance marker must be rejected") + } +} + +func TestEnsureInstanceRejectsVaultOverlap(t *testing.T) { + dir := t.TempDir() + data := filepath.Join(dir, "data") + vault := filepath.Join(data, "vault") // vault 在 data 内:非独立 + if _, err := EnsureInstance(testCfg(t, "gamma", data, vault)); err == nil { + t.Fatal("vault inside data dir must be rejected") + } + vaultOut := filepath.Join(dir, "vault") + if _, err := EnsureInstance(testCfg(t, "gamma", data, vaultOut)); err != nil { + t.Fatalf("independent vault should pass: %v", err) + } +} + +func TestEnsureInstanceRejectsInvalidID(t *testing.T) { + dir := t.TempDir() + for _, bad := range []string{"", "a/b", "..", "a b", "."} { + if _, err := EnsureInstance(testCfg(t, bad, filepath.Join(dir, "x"), "")); err == nil { + t.Fatalf("invalid instance id %q must be rejected", bad) + } + } +} + +func TestEnsureInstanceRejectsRootAsDataDir(t *testing.T) { + if _, err := EnsureInstance(testCfg(t, "root-try", "/", "")); err == nil { + t.Fatal("filesystem root as data dir must be rejected") + } +} + +func TestEnsureInstanceDetectsDuplicateStart(t *testing.T) { + dir := t.TempDir() + data := filepath.Join(dir, "dup") + own, err := EnsureInstance(testCfg(t, "dup", data, "")) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + defer own.Release() + // 同机第二次取得(独立 open file description):必须拒绝误重复启动 + if _, err := EnsureInstance(testCfg(t, "dup", data, "")); err == nil { + t.Fatal("duplicate start must be detected and rejected") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c93517ae..72081410 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type Config struct { Env string `json:"env"` + InstanceID string `json:"instance_id"` DataDir string `json:"data_dir"` HTTPHost string `json:"http_host"` HTTPPort int `json:"http_port"` @@ -121,6 +122,7 @@ func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string] } cfg := &Config{ Env: "dev", + InstanceID: "default", DataDir: DefaultDataDir(), HTTPHost: "127.0.0.1", HTTPPort: 8080, @@ -210,6 +212,9 @@ func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string] if v := os.Getenv("ENV"); v != "" { cfg.Env = v } + if v := os.Getenv("INSTANCE_ID"); v != "" { + cfg.InstanceID = v + } if v := os.Getenv("DATA_DIR"); v != "" { cfg.DataDir = v } @@ -506,6 +511,8 @@ func applyEnvFile(cfg *Config, path string) error { switch key { case "ENV": cfg.Env = val + case "INSTANCE_ID": + cfg.InstanceID = val case "DATA_DIR": cfg.DataDir = val case "APP_PUBLISH_HOST", "HTTP_HOST": diff --git a/internal/config/instance_settings.go b/internal/config/instance_settings.go index a6dc334c..4af22e30 100644 --- a/internal/config/instance_settings.go +++ b/internal/config/instance_settings.go @@ -22,7 +22,8 @@ type InstanceSettings struct { // bootstrapFields 是 instance-settings 永不生效的定位/身份字段 // (自覆盖会构成“用文件定位文件”的循环或变更实例身份)。 var bootstrapFields = map[string]bool{ - "env": true, // 实例环境名属启动定位,仅 .env/进程 env/CLI 可定 + "env": true, // 实例环境名属启动定位 + "instance_id": true, // 实例身份与数据根标记绑定,不可被部署文件改写 } // DefaultConfigRoot 解析配置根:显式 CONFIG_ROOT 进程变量,否则工作目录。