Merge pull request 'U10c 首切片:实例身份标记/单启锁/可写根校验 + systemd 模板' (#15) from feature/u10c-instance-bootstrap-2026-09-29 into dev
This commit was merged in pull request #15.
This commit is contained in:
@@ -25,6 +25,8 @@ imports:
|
|||||||
- config
|
- config
|
||||||
authz:
|
authz:
|
||||||
- docduty
|
- docduty
|
||||||
|
bootstrap:
|
||||||
|
- config
|
||||||
bizdev:
|
bizdev:
|
||||||
- agent
|
- agent
|
||||||
- app/uow
|
- app/uow
|
||||||
@@ -246,6 +248,7 @@ imports:
|
|||||||
- auth
|
- auth
|
||||||
- authz
|
- authz
|
||||||
- bizdev
|
- bizdev
|
||||||
|
- bootstrap
|
||||||
- chat
|
- chat
|
||||||
- config
|
- config
|
||||||
- contracts/vocabulary
|
- contracts/vocabulary
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"chat-agent-system/internal/app"
|
"chat-agent-system/internal/app"
|
||||||
"chat-agent-system/internal/app/eventsconsumer"
|
"chat-agent-system/internal/app/eventsconsumer"
|
||||||
|
"chat-agent-system/internal/bootstrap"
|
||||||
"chat-agent-system/internal/config"
|
"chat-agent-system/internal/config"
|
||||||
"chat-agent-system/internal/localpg"
|
"chat-agent-system/internal/localpg"
|
||||||
"chat-agent-system/internal/server"
|
"chat-agent-system/internal/server"
|
||||||
@@ -64,6 +65,14 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|||||||
@@ -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/<version>/agent-system 不可变程序(多实例共享)
|
||||||
|
# /etc/agent-system/<instance>/env 实例环境文件(0600)
|
||||||
|
# /var/lib/agent-system/<instance>/ 实例数据根(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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string `json:"env"`
|
Env string `json:"env"`
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
HTTPHost string `json:"http_host"`
|
HTTPHost string `json:"http_host"`
|
||||||
HTTPPort int `json:"http_port"`
|
HTTPPort int `json:"http_port"`
|
||||||
@@ -121,6 +122,7 @@ func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string]
|
|||||||
}
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: "dev",
|
Env: "dev",
|
||||||
|
InstanceID: "default",
|
||||||
DataDir: DefaultDataDir(),
|
DataDir: DefaultDataDir(),
|
||||||
HTTPHost: "127.0.0.1",
|
HTTPHost: "127.0.0.1",
|
||||||
HTTPPort: 8080,
|
HTTPPort: 8080,
|
||||||
@@ -210,6 +212,9 @@ func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string]
|
|||||||
if v := os.Getenv("ENV"); v != "" {
|
if v := os.Getenv("ENV"); v != "" {
|
||||||
cfg.Env = v
|
cfg.Env = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("INSTANCE_ID"); v != "" {
|
||||||
|
cfg.InstanceID = v
|
||||||
|
}
|
||||||
if v := os.Getenv("DATA_DIR"); v != "" {
|
if v := os.Getenv("DATA_DIR"); v != "" {
|
||||||
cfg.DataDir = v
|
cfg.DataDir = v
|
||||||
}
|
}
|
||||||
@@ -506,6 +511,8 @@ func applyEnvFile(cfg *Config, path string) error {
|
|||||||
switch key {
|
switch key {
|
||||||
case "ENV":
|
case "ENV":
|
||||||
cfg.Env = val
|
cfg.Env = val
|
||||||
|
case "INSTANCE_ID":
|
||||||
|
cfg.InstanceID = val
|
||||||
case "DATA_DIR":
|
case "DATA_DIR":
|
||||||
cfg.DataDir = val
|
cfg.DataDir = val
|
||||||
case "APP_PUBLISH_HOST", "HTTP_HOST":
|
case "APP_PUBLISH_HOST", "HTTP_HOST":
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ type InstanceSettings struct {
|
|||||||
// bootstrapFields 是 instance-settings 永不生效的定位/身份字段
|
// bootstrapFields 是 instance-settings 永不生效的定位/身份字段
|
||||||
// (自覆盖会构成“用文件定位文件”的循环或变更实例身份)。
|
// (自覆盖会构成“用文件定位文件”的循环或变更实例身份)。
|
||||||
var bootstrapFields = map[string]bool{
|
var bootstrapFields = map[string]bool{
|
||||||
"env": true, // 实例环境名属启动定位,仅 .env/进程 env/CLI 可定
|
"env": true, // 实例环境名属启动定位
|
||||||
|
"instance_id": true, // 实例身份与数据根标记绑定,不可被部署文件改写
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfigRoot 解析配置根:显式 CONFIG_ROOT 进程变量,否则工作目录。
|
// DefaultConfigRoot 解析配置根:显式 CONFIG_ROOT 进程变量,否则工作目录。
|
||||||
|
|||||||
Reference in New Issue
Block a user