Open identity commands on an existing database only #184
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <serve|version|identity|user|apikey|config> [参数]")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
|
||||
+26
-4
@@ -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 的整型缓存读(限流值热读取免打库)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ",")
|
||||
}
|
||||
|
||||
+130
-22
@@ -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 <ensure|verify|capabilities> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]"
|
||||
// 与 "ensure" 字样(旧 svc 探测仍按 usage 形态判有能力)。
|
||||
const Usage = "用法:identity <ensure|verify|capabilities> --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 路径 <data>/agent-system.db)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite;亦可用环境 AGENT_SYSTEM_DSN 交付——凭据不进命令行)")
|
||||
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc-<group>-…)")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user