diff --git a/cmd/agent-system/dispatch_test.go b/cmd/agent-system/dispatch_test.go new file mode 100644 index 0000000..d4f7096 --- /dev/null +++ b/cmd/agent-system/dispatch_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestUnknownSubcommandExits(t *testing.T) { + if os.Getenv("DISPATCH_CHILD") == "1" { + os.Args = []string{"agent-system", "not-a-command"} + main() + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestUnknownSubcommandExits") + cmd.Env = append(os.Environ(), "DISPATCH_CHILD=1") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("未知子命令应非零退出:%s", out) + } + if !strings.Contains(string(out), "未知子命令") { + t.Fatalf("输出:%s", out) + } + if strings.Contains(string(out), "agent-system starting") || strings.Contains(string(out), "serving") { + t.Fatalf("未知子命令落入了服务路径:%s", out) + } +} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 391d27a..f529c67 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" "os" + "strings" "time" "agent-system-backend/internal/app" @@ -20,9 +21,16 @@ import ( var version = "0.1.0-dev" func main() { - if len(os.Args) > 1 { + if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { switch os.Args[1] { case "version", "--version": + if len(os.Args) > 2 && os.Args[2] == "--json" { + if err := identity.WriteDocument(os.Stdout, "agent-system", version); err != nil { + fmt.Fprintln(os.Stderr, "version:", err) + os.Exit(1) + } + return + } fmt.Println("agent-system", version) return case "serve": @@ -35,6 +43,10 @@ func main() { os.Exit(runUser(os.Args[2:])) case "identity": os.Exit(runIdentity(os.Args[2:])) + default: + fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1]) + fmt.Fprintln(os.Stderr, "用法:agent-system [参数]") + os.Exit(2) } } fs := flag.NewFlagSet("serve", flag.ExitOnError) diff --git a/internal/app/app.go b/internal/app/app.go index 2244da4..314ad68 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -836,6 +836,16 @@ func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error { return withStoreMode(storageDSN, dataDir, false, fn) } +// withIdentityStore 只打开既有库:不建文件、不建目录、不迁移。 +func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) error { + st, err := store.OpenIdentity(storageDSN, dataDir) + if err != nil { + return err + } + defer st.Close() + return fn(st) +} + func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { var st *store.Store var err error @@ -912,17 +922,29 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) { } // EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。 +// 只打开既有库,不建库、不迁移。 func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error { - return withStore(storageDSN, dataDir, func(st *store.Store) error { + return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error { return st.EnsureSystemIdentity(instanceID, dbref) }) } // VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。 -func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error { - return withStore(storageDSN, dataDir, func(st *store.Store) error { - return st.VerifySystemIdentity(instanceID, dbref) +// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。 +func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) { + var schemaVersion int + err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error { + if err := st.VerifySystemIdentity(instanceID, dbref); err != nil { + return err + } + row, err := st.GetSystemIdentity() + if err != nil { + return err + } + schemaVersion = row.SchemaVersion + return nil }) + return schemaVersion, err } // cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。 diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go new file mode 100644 index 0000000..cd8e6fb --- /dev/null +++ b/internal/store/migrate_compat.go @@ -0,0 +1,49 @@ +package store + +// migrationBreaksOldReaders 登记每个迁移版本落地后,schema.target 更低的旧制品 +// 还能不能读这个库。新增迁移漏登记时 TestMigrationCompatRegistered 失败。 +// true 会抬高 SchemaCompatFloor:候选若要把库迁过该版本,而旧制品的 target +// 低于该下限,切换必须在停旧服务前拒绝,除非走已确认的 forward-only 迁移。 +var migrationBreaksOldReaders = map[int]bool{ + 1: false, + 2: false, + 3: false, + 4: false, + 5: false, + 6: false, + 7: false, + 8: false, + 9: false, + 10: false, + 11: false, + 12: false, + 13: false, + 14: false, + 15: false, + 16: false, + 17: false, + 18: false, + 19: false, + 20: false, + 21: false, + 22: false, + 23: false, + 24: false, + 25: false, + 26: false, + 27: false, +} + +// SchemaTarget 是本二进制会迁移到的 schema 版本。 +func SchemaTarget() int { return latestMigrationVersion() } + +// SchemaCompatFloor 是旧制品仍能读取的最高破坏性版本。0 表示当前全部迁移都不破坏旧读。 +func SchemaCompatFloor() int { + floor := 0 + for version, breaks := range migrationBreaksOldReaders { + if breaks && version > floor { + floor = version + } + } + return floor +} diff --git a/internal/store/migrate_compat_test.go b/internal/store/migrate_compat_test.go new file mode 100644 index 0000000..6c46cf7 --- /dev/null +++ b/internal/store/migrate_compat_test.go @@ -0,0 +1,20 @@ +package store + +import "testing" + +func TestMigrationCompatRegistered(t *testing.T) { + if len(migrationBreaksOldReaders) != len(migrations) { + t.Fatalf("兼容登记 %d 条,迁移 %d 条", len(migrationBreaksOldReaders), len(migrations)) + } + for _, m := range migrations { + if _, ok := migrationBreaksOldReaders[m.version]; !ok { + t.Fatalf("迁移 v%d %s 未登记是否破坏旧制品读取", m.version, m.name) + } + } + if SchemaTarget() != latestMigrationVersion() { + t.Fatalf("target=%d latest=%d", SchemaTarget(), latestMigrationVersion()) + } + if SchemaCompatFloor() < 0 || SchemaCompatFloor() > SchemaTarget() { + t.Fatalf("compatFloor=%d target=%d", SchemaCompatFloor(), SchemaTarget()) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ba64e01..edfff68 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -90,6 +90,87 @@ func OpenSQLite(path string) (*Store, error) { return openSQLiteFile(path, true) } +// OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。 +// 缺少 agent_system_identity 表时返回 ErrIdentityTableMissing。 +func OpenIdentity(storageDSN, dataDir string) (*Store, error) { + if storageDSN != "" { + return openIdentityPostgres(storageDSN) + } + return openIdentitySQLite(filepath.Join(dataDir, "agent-system.db")) +} + +func openIdentitySQLite(path string) (*Store, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrIdentityDBMissing, path) + } + return nil, err + } + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=foreign_keys(1)&_txlock=immediate", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if err := requireIdentityTable(db, "sqlite"); err != nil { + db.Close() + return nil, err + } + return &Store{db: db, dialect: sqliteDialect{}}, nil +} + +func openIdentityPostgres(dsn string) (*Store, error) { + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(2) + if err := db.Ping(); err != nil { + db.Close() + return nil, err + } + if err := requireIdentityTable(db, "postgres"); err != nil { + db.Close() + return nil, err + } + return &Store{db: db, dialect: postgresDialect{}}, nil +} + +func requireIdentityTable(db *sql.DB, kind string) error { + var name string + var err error + switch kind { + case "postgres": + err = db.QueryRow(`SELECT COALESCE(to_regclass('public.agent_system_identity')::text, '')`).Scan(&name) + default: + err = db.QueryRow(`SELECT COALESCE((SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'), '')`).Scan(&name) + } + if err != nil { + return err + } + if name == "" { + return fmt.Errorf("%w: 先执行受控迁移(服务启动或 migrate),身份命令不建库、不迁移", ErrIdentityTableMissing) + } + return nil +} + +// SchemaVersion 读取 schema_migrations 的最大版本。表不存在时返回 0。 +func (s *Store) SchemaVersion() (int, error) { + var v sql.NullInt64 + err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&v) + if err != nil { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "no such table") || strings.Contains(msg, "does not exist") { + return 0, nil + } + return 0, err + } + if !v.Valid { + return 0, nil + } + return int(v.Int64), nil +} + // OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。 // 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。 func OpenExistingSQLite(path string) (*Store, error) { diff --git a/internal/store/system_identity.go b/internal/store/system_identity.go index 0aeaaa5..453f2ad 100644 --- a/internal/store/system_identity.go +++ b/internal/store/system_identity.go @@ -12,6 +12,15 @@ var ErrSystemIdentityMismatch = errors.New("system identity mismatch") // ErrSystemIdentityMissing 表中无身份行——须先 Ensure(svc init / 首次建立),禁止静默插入。 var ErrSystemIdentityMissing = errors.New("system identity missing") +// ErrIdentityDBMissing 身份 CLI 拒绝创建数据库文件或父目录。 +var ErrIdentityDBMissing = errors.New("identity database missing") + +// ErrIdentityTableMissing 库存在但没有冻结的身份表,须走受控迁移而不是身份 CLI。 +var ErrIdentityTableMissing = errors.New("identity table missing") + +// IdentityColumns 是 agent_system_identity 的冻结列序(v27)。新增列会破坏旧制品读取,必须先改本常量并补兼容测试。 +const IdentityColumns = "instance_id,dbref,schema_version,created_at" + // SystemIdentity 是 agent_system_identity 表的一行(一库一服务身份)。 type SystemIdentity struct { InstanceID string @@ -43,7 +52,13 @@ func (s *Store) EnsureSystemIdentity(instanceID, dbref string) error { } return nil } - ver := latestMigrationVersion() + ver, err := s.SchemaVersion() + if err != nil { + return err + } + if ver <= 0 { + ver = 27 + } _, err = s.db.Exec(s.q(`INSERT INTO agent_system_identity(instance_id, dbref, schema_version, created_at) VALUES(?,?,?,?)`), instanceID, dbref, ver, nowISO()) if err != nil { diff --git a/internal/store/system_identity_test.go b/internal/store/system_identity_test.go index f0b4f6e..9edaea7 100644 --- a/internal/store/system_identity_test.go +++ b/internal/store/system_identity_test.go @@ -1,8 +1,11 @@ package store import ( + "database/sql" "errors" + "os" "path/filepath" + "strings" "testing" ) @@ -67,4 +70,89 @@ func TestSystemIdentityMigrationCreatesTable(t *testing.T) { if err := st.DB().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'`).Scan(&name); err != nil || name != "agent_system_identity" { t.Fatalf("table missing: name=%q err=%v", name, err) } + cols := identityColumnNames(t, st.DB()) + if cols != IdentityColumns { + t.Fatalf("身份表列已偏离冻结契约:%s", cols) + } +} + +func TestOpenIdentityReadsFutureSchemaWithoutMigrating(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent-system.db") + raw, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + stmts := []string{ + `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`, + `INSERT INTO schema_migrations(version, name, applied_at) VALUES (99, 'future', 't')`, + `CREATE TABLE agent_system_identity ( + instance_id TEXT NOT NULL PRIMARY KEY, + dbref TEXT NOT NULL, + schema_version INTEGER NOT NULL, + created_at TEXT NOT NULL)`, + `INSERT INTO agent_system_identity(instance_id, dbref, schema_version, created_at) VALUES ('svc-future-aaaa', 'sqlite', 99, 't')`, + } + for _, stmt := range stmts { + if _, err := raw.Exec(stmt); err != nil { + t.Fatal(err) + } + } + raw.Close() + st, err := OpenIdentity("", dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.VerifySystemIdentity("svc-future-aaaa", "sqlite"); err != nil { + t.Fatal(err) + } + ver, err := st.SchemaVersion() + if err != nil || ver != 99 { + t.Fatalf("schema=%d err=%v,身份打开不得改写迁移版本", ver, err) + } + if cols := identityColumnNames(t, st.DB()); cols != IdentityColumns { + t.Fatalf("列=%s", cols) + } +} + +func TestOpenIdentityMissingFileOrTable(t *testing.T) { + dir := t.TempDir() + if _, err := OpenIdentity("", filepath.Join(dir, "missing")); !errors.Is(err, ErrIdentityDBMissing) { + t.Fatalf("缺文件:%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "missing")); !os.IsNotExist(err) { + t.Fatal("缺库时创建了目录") + } + path := filepath.Join(dir, "agent-system.db") + raw, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + if _, err := raw.Exec(`CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil { + t.Fatal(err) + } + raw.Close() + parent := filepath.Dir(path) + if _, err := OpenIdentity("", parent); !errors.Is(err, ErrIdentityTableMissing) { + t.Fatalf("缺表:%v", err) + } +} + +func identityColumnNames(t *testing.T, db *sql.DB) string { + t.Helper() + rows, err := db.Query(`SELECT name FROM pragma_table_info('agent_system_identity') ORDER BY cid`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + names = append(names, name) + } + return strings.Join(names, ",") } diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 88324a2..76a4e92 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -1,26 +1,91 @@ // Package identity 提供服务实例库内身份子命令的公共 CLI(U1 I3: // manifest.instance_id ↔ agent_system_identity 表)。 -// 独立可导出包:下游组合仓(Agent-System-RDM 等)与基座二进制共用同一实现, -// identity 语义跨产物一致。契约(2026-10-12 rdm1/rdmcand 事故后确立): -// - 无参/未知子命令打印用法并以非零码退出,绝不落入服务启动路径—— -// 部署侧能力探测必须无副作用; -// - `identity capabilities` 输出版本化能力清单,供探测优先消费。 +// 独立可导出包:下游组合仓(Agent-System-RDM 等)与基座二进制共用同一实现。 +// 契约:无参/未知子命令打印用法并以非零码退出;只打开既有库,不建库、不迁移。 package identity import ( + "encoding/json" "flag" "fmt" + "io" "os" + "runtime/debug" + "strings" "agent-system-backend/internal/app" + "agent-system-backend/internal/store" ) // Usage 无参/未知子命令时打印到 stderr 的用法行。探测契约:同时含 "identity" -// 与 "ensure" 字样(svc.sh svc_bin_has_identity 按 usage 形态判有能力)。 -const Usage = "用法:identity --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]" +// 与 "ensure" 字样(旧 svc 探测仍按 usage 形态判有能力)。 +const Usage = "用法:identity --instance-id ID [--dbref REF] [--data DIR] [--dsn-file FILE]" -// Capabilities 版本化能力输出(新增子命令在此登记并递增 vN)。 -const Capabilities = "identity-capabilities: v1 ensure,verify" +// Product 写入能力文档的产品名。组合仓在调用 Run 前改成自己的名字。 +var Product = "agent-system" + +// Document 是 version --json 与 identity capabilities 共用的能力文档。 +type Document struct { + Protocol int `json:"protocol"` + Product string `json:"product"` + Version string `json:"version"` + Identity IdentityCaps `json:"identity"` + CLI CLICaps `json:"cli"` + Schema *SchemaCaps `json:"schema,omitempty"` +} + +// IdentityCaps 描述身份子命令的打开方式。 +type IdentityCaps struct { + Ops []string `json:"ops"` + Open string `json:"open"` + DSN string `json:"dsn"` +} + +// CLICaps 描述未知子命令的行为。 +type CLICaps struct { + UnknownSubcommand string `json:"unknownSubcommand"` +} + +// SchemaCaps 由 D2 填入。零值时从 JSON 省略。 +type SchemaCaps struct { + Target int `json:"target"` + CompatFloor int `json:"compatFloor"` +} + +// NewDocument 构造当前进程的能力文档。 +func NewDocument(product, version string) Document { + if product == "" { + product = Product + } + if version == "" { + version = binaryVersion() + } + return Document{ + Protocol: 1, + Product: product, + Version: version, + Identity: IdentityCaps{Ops: []string{"ensure", "verify"}, Open: "existing-no-migrate", DSN: "env"}, + CLI: CLICaps{UnknownSubcommand: "reject"}, + Schema: &SchemaCaps{Target: store.SchemaTarget(), CompatFloor: store.SchemaCompatFloor()}, + } +} + +// WriteDocument 把能力文档写成一行 JSON。 +func WriteDocument(w io.Writer, product, version string) error { + raw, err := json.Marshal(NewDocument(product, version)) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", raw) + return err +} + +func binaryVersion() string { + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + return bi.Main.Version + } + return "(devel)" +} // Run 执行 identity 子命令;args 不含 "identity" 自身,返回进程退出码。 func Run(args []string) int { @@ -30,27 +95,36 @@ func Run(args []string) int { } sub := args[0] if sub == "capabilities" { - fmt.Println(Capabilities) + if err := WriteDocument(os.Stdout, Product, ""); err != nil { + fmt.Fprintln(os.Stderr, "identity capabilities:", err) + return 1 + } return 0 } - fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError) + fs := flag.NewFlagSet("identity "+sub, flag.ContinueOnError) + fs.SetOutput(io.Discard) data := fs.String("data", "./data", "数据目录(SQLite 路径 /agent-system.db)") - dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite;亦可用环境 AGENT_SYSTEM_DSN 交付——凭据不进命令行)") - instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc--…)") + dsnFlag := fs.String("dsn", "", "PostgreSQL DSN(会进入进程参数;优先用环境变量或 --dsn-file)") + dsnFile := fs.String("dsn-file", "", "权限恰好 0600 的 DSN 文件") + instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id)") dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)") - fs.Parse(args[1:]) - // DSN 交付纪律(G1-7 同源):调用方(svc.sh)经 AGENT_SYSTEM_DSN 环境交付, - // 命令行对同机任何进程可见——显式 --dsn 仍最高优先,其次环境,最后嵌入式。 - if *dsn == "" { - *dsn = os.Getenv("AGENT_SYSTEM_DSN") + if err := fs.Parse(args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "identity:", err) + fmt.Fprintln(os.Stderr, Usage) + return 2 } if *instanceID == "" { fmt.Fprintln(os.Stderr, "identity: --instance-id 必填") return 2 } + dsn, err := ResolveDSN(*dsnFlag, *dsnFile) + if err != nil { + fmt.Fprintln(os.Stderr, "identity:", err) + return 2 + } ref := *dbref if ref == "" { - if *dsn != "" { + if dsn != "" { ref = "pg" } else { ref = "sqlite" @@ -58,18 +132,19 @@ func Run(args []string) int { } switch sub { case "ensure": - if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil { + if err := app.EnsureSystemIdentity(dsn, *data, *instanceID, ref); err != nil { fmt.Fprintln(os.Stderr, "identity ensure:", err) return 1 } fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref) return 0 case "verify": - if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil { + ver, err := app.VerifySystemIdentity(dsn, *data, *instanceID, ref) + if err != nil { fmt.Fprintln(os.Stderr, "identity verify:", err) return 1 } - fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref) + fmt.Printf("verified instance_id=%s dbref=%s schema_version=%d\n", *instanceID, ref, ver) return 0 default: fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub) @@ -77,3 +152,36 @@ func Run(args []string) int { return 2 } } + +// ResolveDSN 按 --dsn-file、环境变量、--dsn 的顺序取值。--dsn 会告警。 +// --dsn 与 --dsn-file 同时出现则拒绝。文件权限必须恰好 0600,且不能是符号链接。 +func ResolveDSN(flagDSN, flagFile string) (string, error) { + if flagDSN != "" && flagFile != "" { + return "", fmt.Errorf("--dsn 与 --dsn-file 只能选一个") + } + if flagFile != "" { + st, err := os.Lstat(flagFile) + if err != nil { + return "", err + } + if st.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("--dsn-file 不能是符号链接") + } + if st.Mode().Perm() != 0o600 { + return "", fmt.Errorf("--dsn-file 权限必须为 0600(当前 %o)", st.Mode().Perm()) + } + raw, err := os.ReadFile(flagFile) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil + } + if env := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DSN")); env != "" && flagDSN == "" { + return env, nil + } + if flagDSN != "" { + fmt.Fprintln(os.Stderr, "identity: 警告:--dsn 会进入进程参数,请改用 AGENT_SYSTEM_DSN 或 --dsn-file") + return flagDSN, nil + } + return "", nil +} diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go index 2d14dd6..81a238f 100644 --- a/pkg/identity/identity_test.go +++ b/pkg/identity/identity_test.go @@ -1,11 +1,14 @@ package identity import ( + "encoding/json" "io" "os" "path/filepath" "strings" "testing" + + "agent-system-backend/internal/store" ) // capture 把 stdout/stderr 重定向到管道并在 fn 返回后给出捕获文本。 @@ -51,21 +54,60 @@ func TestUsageAndUnknownExitWithoutServe(t *testing.T) { func TestCapabilities(t *testing.T) { out, _ := capture(t, func() { Run([]string{"capabilities"}) }) - if !strings.HasPrefix(out, "identity-capabilities: v1") || - !strings.Contains(out, "ensure") || !strings.Contains(out, "verify") { - t.Fatalf("capabilities 输出异常:%q", out) + var doc Document + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &doc); err != nil { + t.Fatalf("capabilities 不是 JSON:%v %q", err, out) + } + if doc.Protocol != 1 || doc.Identity.Open != "existing-no-migrate" || doc.CLI.UnknownSubcommand != "reject" { + t.Fatalf("能力文档异常:%+v", doc) + } + if doc.Schema == nil || doc.Schema.Target != store.SchemaTarget() || doc.Schema.CompatFloor != store.SchemaCompatFloor() { + t.Fatalf("schema 能力异常:%+v", doc.Schema) } } -func TestEnsureVerifyRoundTripSQLite(t *testing.T) { +func migratedData(t *testing.T) (string, int) { + t.Helper() dir := t.TempDir() - args := []string{"--data", filepath.Join(dir, "d1")} + data := filepath.Join(dir, "data") + st, err := store.OpenSQLite(filepath.Join(data, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + ver, err := st.SchemaVersion() + if err != nil { + t.Fatal(err) + } + st.Close() + return data, ver +} + +func TestEnsureVerifyRoundTripSQLite(t *testing.T) { + data, before := migratedData(t) + args := []string{"--data", data} id := "svc-t1-abcdef123456" if rc := Run(append([]string{"ensure", "--instance-id", id}, args...)); rc != 0 { t.Fatalf("ensure rc=%d", rc) } - if rc := Run(append([]string{"verify", "--instance-id", id}, args...)); rc != 0 { - t.Fatalf("verify rc=%d", rc) + out, _ := capture(t, func() { + if rc := Run(append([]string{"verify", "--instance-id", id}, args...)); rc != 0 { + t.Fatalf("verify rc=%d", rc) + } + }) + if !strings.Contains(out, "schema_version=") { + t.Fatalf("verify 未带 schema_version:%q", out) + } + st, err := store.OpenIdentity("", data) + if err != nil { + t.Fatal(err) + } + defer st.Close() + after, err := st.SchemaVersion() + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("身份命令改变了 schema:%d → %d", before, after) } if rc := Run(append([]string{"verify", "--instance-id", "svc-t1-zzzzzz999999"}, args...)); rc != 1 { t.Fatalf("错 id 须 rc=1,得 %d", rc) @@ -75,17 +117,57 @@ func TestEnsureVerifyRoundTripSQLite(t *testing.T) { } } -// DSN 交付纪律:AGENT_SYSTEM_DSN 环境回退生效(凭据不进命令行)。 -func TestDSNEnvFallback(t *testing.T) { - t.Setenv("AGENT_SYSTEM_DSN", "") - // 环境置位时空 --dsn 生效口径由真 PG 专项验证;此处仅验证不干扰 SQLite 路径 - //(环境为空 → 仍走嵌入式)与 flag 优先级不回归。 +func TestVerifyMissingDBCreatesNothing(t *testing.T) { dir := t.TempDir() - id := "svc-dsnenv-abcdef123456" - if rc := Run([]string{"ensure", "--instance-id", id, "--data", dir}); rc != 0 { - t.Fatalf("ensure rc=%d", rc) + missing := filepath.Join(dir, "absent") + if rc := Run([]string{"verify", "--instance-id", "svc-missing-aaaa", "--data", missing}); rc == 0 { + t.Fatal("缺库不应成功") } - if rc := Run([]string{"verify", "--instance-id", id, "--data", dir}); rc != 0 { + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Fatalf("缺库路径被创建:%v", err) + } + if _, err := os.Stat(filepath.Join(missing, "agent-system.db")); !os.IsNotExist(err) { + t.Fatal("缺库时创建了数据库文件") + } +} + +func TestDSNFileMode(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "dsn") + if err := os.WriteFile(good, []byte("postgres://example\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveDSN("", good) + if err != nil || got != "postgres://example" { + t.Fatalf("0600 文件:%q %v", got, err) + } + loose := filepath.Join(dir, "loose") + if err := os.WriteFile(loose, []byte("postgres://x"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ResolveDSN("", loose); err == nil { + t.Fatal("0644 的 dsn 文件必须拒绝") + } + _, errS := capture(t, func() { ResolveDSN("postgres://flag", "") }) + if !strings.Contains(errS, "警告") { + t.Fatalf("--dsn 应告警:%q", errS) + } +} + +// DSN 交付纪律:空的 AGENT_SYSTEM_DSN 不干扰已有 SQLite 库。 +func TestDSNEnvFallback(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + data, _ := migratedData(t) + id := "svc-dsnenv-abcdef123456" + if rc := Run([]string{"ensure", "--instance-id", id, "--data", data}); rc != 0 { + t.Fatalf("ensure rc=%d", rc) + } + if rc := Run([]string{"verify", "--instance-id", id, "--data", data}); rc != 0 { t.Fatalf("verify rc=%d", rc) + } + t.Setenv("AGENT_SYSTEM_DSN", "postgres://from-env") + got, err := ResolveDSN("", "") + if err != nil || got != "postgres://from-env" { + t.Fatalf("环境 DSN:%q %v", got, err) } }