- 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
158 lines
5.2 KiB
Go
158 lines
5.2 KiB
Go
// 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
|
|
}
|