ci / vet-and-test (pull_request) Successful in 47s
Why: a rollback can only be safe when the candidate says which schema it will migrate to and whether older binaries can still read that schema. A new migration that forgets to register that fact must fail the test. Evidence: go test ./internal/store/ ./pkg/identity/ -run 'TestMigrationCompat|TestCapabilities' passed. Impact: version --json and identity capabilities include schema.target and schema.compatFloor. Every migration version is listed in migrationBreaksOldReaders. Current versions do not break old readers, so the floor stays 0. Refs: recovery plan r3.1 D2 Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
5.7 KiB
Go
188 lines
5.7 KiB
Go
// Package identity 提供服务实例库内身份子命令的公共 CLI(U1 I3:
|
||
// manifest.instance_id ↔ agent_system_identity 表)。
|
||
// 独立可导出包:下游组合仓(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 探测仍按 usage 形态判有能力)。
|
||
const Usage = "用法:identity <ensure|verify|capabilities> --instance-id ID [--dbref REF] [--data DIR] [--dsn-file FILE]"
|
||
|
||
// 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 {
|
||
if len(args) == 0 {
|
||
fmt.Fprintln(os.Stderr, Usage)
|
||
return 2
|
||
}
|
||
sub := args[0]
|
||
if sub == "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.ContinueOnError)
|
||
fs.SetOutput(io.Discard)
|
||
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
|
||
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)")
|
||
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 != "" {
|
||
ref = "pg"
|
||
} else {
|
||
ref = "sqlite"
|
||
}
|
||
}
|
||
switch sub {
|
||
case "ensure":
|
||
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":
|
||
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 schema_version=%d\n", *instanceID, ref, ver)
|
||
return 0
|
||
default:
|
||
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
|
||
fmt.Fprintln(os.Stderr, Usage)
|
||
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
|
||
}
|