diff --git a/CHANGELOG.md b/CHANGELOG.md index ba808e9..a45a3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 开发快照(封存入口接线:库级窗口接上实例级闸门) + +### Fixed + +- **主机侧封存不再用"库级窗口"跑实例**:`libraryctl capture` / `recover` 改由 + `librarymaintenance.Manager` 装配——入口闸门、后台静默检查、目标指纹、入口探针与发布链路 + 是同一套实现。实例级维护里"写入者 exit=1"不再等于"在途请求被截断":被代理的 WeKnora 收到 + SIGTERM 就 `exit(1)` 是已知上游行为,排空证据来自闸门(`active_at_close` / `drained`), + 校验没有放松,而是补上了缺的那一层证据。 +- 缺少 `LIBRARY_TARGET_INGRESS_*` / `LIBRARY_TARGET_REDIS_*` 时 `capture` 在**开窗口前拒绝** + 并点名缺哪个变量;不再退回一个没有排空证据的窗口(那样的"成功"无法复核)。 +- 维护接线解析收敛到 `internal/librarymaintenance/env.go`:agent 与 worker 讲同一组变量名, + 空白值算缺失、队列库号必须是非负整数、口令不 Trim(前后空格也是口令)。 +- `libraryagent` 启动时如实打印维护目标是否接好(缺了就直说"封存会拒绝")。 +- `scripts/library-agent-install.sh` 强制 `agent.env`(0600,`EnvironmentFile=`),并把源容器/ + 项目/写入者声明做成可覆盖变量;没有 env 文件直接 `AGENT_ENV_MISSING` 退出。 + +### Tests + +- `internal/librarymaintenance/env_test.go`:整份声明解析、逐项点名缺失、空白拒绝、 + 口令空格保留、非法队列库号。 +- `cmd/libraryctl/maintenance_test.go`:入口/队列/源身份绑到同一份配置、数据库身份不泄漏口令、 + 写入者声明透传、缺闸门拒绝。 + ## 开发快照(Wiki 撤销贡献:逐页语义比对 + 维护窗口真实呈现) ### Added diff --git a/cmd/libraryagent/main.go b/cmd/libraryagent/main.go index 2aa4c0d..ada1d86 100644 --- a/cmd/libraryagent/main.go +++ b/cmd/libraryagent/main.go @@ -14,6 +14,7 @@ import ( "time" "knowledge-governance/internal/libraryagent" + "knowledge-governance/internal/librarymaintenance" ) func main() { @@ -39,6 +40,14 @@ func main() { if err != nil { log.Fatal(err) } + // 实例级维护接线(入口闸门 + 后台队列)不在 agent 的 Config 里:它由同一份 + // 环境声明提供给子进程。启动时先说清楚有没有——缺了封存会在开窗口前被拒, + // 不允许"以为接好了"跑到一半才发现没有排空证据。 + if target, targetErr := librarymaintenance.TargetFromEnv(os.Getenv); targetErr != nil { + log.Printf("library agent: 封存会拒绝(实例级维护未接线):%v", targetErr) + } else { + log.Printf("library agent: 实例级维护目标 %s 入口 %s 闸门 %s", target.Instance, target.IngressURL, target.IngressService) + } if *describe { log.Printf("library agent config: %+v", agent.Describe()) return diff --git a/cmd/libraryctl/main.go b/cmd/libraryctl/main.go index d99803c..2a28ea1 100644 --- a/cmd/libraryctl/main.go +++ b/cmd/libraryctl/main.go @@ -33,6 +33,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "knowledge-governance/internal/libraryfreeze" + "knowledge-governance/internal/librarymaintenance" "knowledge-governance/internal/librarysnapshot" v071 "knowledge-governance/internal/releaseadapter/v071" releaseworkflow "knowledge-governance/internal/workflows/release" @@ -451,6 +452,78 @@ func openFreeze(ctx context.Context, opts options) (*libraryfreeze.Coordinator, return coordinator, control, nil } +// captureMaintenanceConfig 把"这次封存动的是哪个实例"落成实例级维护目标。 +// +// 为什么必须实例级:被代理的 WeKnora 收到 SIGTERM 会直接 exit(1),写者的退出码 +// 因此永远不能当作"在途请求已排空"。排空证据只能在真正的入口上取——关掉闸门、 +// 等它把在途连接放完。没有闸门就没有证据,那种封存不允许以"库级窗口"的名义跑 +// (真跑过:被 exit=1 拒掉,这是校验在起作用,不是要放松的地方)。 +// +// 入口/队列的声明与容器内 worker 共用同一组 LIBRARY_TARGET_* 名称, +// 数据库/索引/桶则取自本次真实发现的源,避免"关的是 A 的门、读的是 B 的队列"。 +func captureMaintenanceConfig(opts options, source sourceConfig, get func(string) string) (librarymaintenance.Config, error) { + target, err := librarymaintenance.TargetFromEnv(get) + if err != nil { + return librarymaintenance.Config{}, err + } + writers, nonWriters, err := writerDeclaration(opts) + if err != nil { + return librarymaintenance.Config{}, err + } + identity, err := librarymaintenance.DatabaseIdentity(source.DatabaseURL) + if err != nil { + return librarymaintenance.Config{}, err + } + return librarymaintenance.Config{ + Instance: target.Instance, + Project: opts.project, + Writers: writers, + NonWriters: nonWriters, + DockerSocket: opts.dockerSocket, + IngressURL: target.IngressURL, + IngressService: target.IngressService, + IngressDrainFile: target.IngressDrainFile, + Database: identity, + Index: source.SearchIndex, + Bucket: source.Object.Bucket, + // 后台静默检查连的是"正在被封的这个源":未结清的后台账本与队列都在它身上。 + DatabaseURL: source.DatabaseURL, + RedisAddr: target.RedisAddr, + RedisPassword: target.RedisPassword, + RedisDB: target.RedisDB, + }, nil +} + +// openMaintenance 用实例级目标装配协调器:入口闸门、后台静默检查、目标指纹、 +// 入口探针全部来自上面那份声明,与发布链路用的是同一套实现(不是第二份协议)。 +func openMaintenance(ctx context.Context, opts options, source sourceConfig) (*libraryfreeze.Coordinator, *libraryfreeze.ProjectController, *librarymaintenance.Manager, error) { + config, err := captureMaintenanceConfig(opts, source, os.Getenv) + if err != nil { + return nil, nil, nil, err + } + manager, err := librarymaintenance.New(config) + if err != nil { + return nil, nil, nil, err + } + // 拓扑先过关:写入者发布了端口 = 存在绕闸直连,这样的"维护"只关了半扇门。 + if err = manager.VerifyTopology(ctx); err != nil { + manager.Close() + return nil, nil, nil, fmt.Errorf("目标拓扑不满足唯一入口约束,拒绝开维护窗口:%w", err) + } + coordinator, control, err := manager.Open(opts.journalRoot) + if err != nil { + manager.Close() + return nil, nil, nil, err + } + return coordinator, control, manager, nil +} + +// discoverMaintenanceSource 只读"这个源是什么",不碰治理库的 scope 绑定: +// 恢复/收尾不该因为治理库读不到就被卡住。 +func discoverMaintenanceSource(ctx context.Context, opts options) (sourceConfig, error) { + return discoverSource(ctx, opts, map[string]string{}) +} + func openArchive(opts options) (*librarysnapshot.Archive, error) { if err := os.MkdirAll(opts.archiveRoot, 0700); err != nil { return nil, err @@ -643,10 +716,21 @@ func runCapture(ctx context.Context, opts options) error { return err } defer archive.Close() - coordinator, control, err := openFreeze(ctx, opts) + binding, err := scopeBindings(ctx, opts.governancePG) if err != nil { return err } + config, err := discoverSource(ctx, opts, binding) + if err != nil { + return err + } + // 维护窗口必须先按"实例级"接好:入口闸门给出在途排空证据,后台静默检查 + // 给出停写者那一刻队列已空。缺任何一项都拒绝封存——没有证据的封存不是候选。 + coordinator, control, manager, err := openMaintenance(ctx, opts, config) + if err != nil { + return err + } + defer manager.Close() defer coordinator.Close() defer control.Close() if err = coordinator.Recover(ctx); err != nil { @@ -660,10 +744,6 @@ func runCapture(ctx context.Context, opts options) error { if err != nil { return err } - binding, err := scopeBindings(ctx, opts.governancePG) - if err != nil { - return err - } scopedKB := binding[opts.scope] if found { manifest, err := replayCapture(job, opts.scope, scopedKB, existing, archive) @@ -672,10 +752,6 @@ func runCapture(ctx context.Context, opts options) error { } return printCapture(job, existing.SnapshotID, manifest, true) } - config, err := discoverSource(ctx, opts, binding) - if err != nil { - return err - } pool, err := openSourceDatabase(ctx, config) if err != nil { return err @@ -957,10 +1033,17 @@ func runVerify(opts options) error { } func runRecover(ctx context.Context, opts options) error { - coordinator, control, err := openFreeze(ctx, opts) + // 收尾也必须走同一份实例级目标:崩溃留下的窗口是"入口已关、写入者已停", + // 恢复要用闸门把入口真的重新打开,没有闸门就没有"目标已恢复"的证据。 + source, err := discoverMaintenanceSource(ctx, opts) if err != nil { return err } + coordinator, control, manager, err := openMaintenance(ctx, opts, source) + if err != nil { + return err + } + defer manager.Close() defer coordinator.Close() defer control.Close() if err = coordinator.Recover(ctx); err != nil { diff --git a/cmd/libraryctl/maintenance_test.go b/cmd/libraryctl/maintenance_test.go new file mode 100644 index 0000000..1a17c8e --- /dev/null +++ b/cmd/libraryctl/maintenance_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "strings" + "testing" + + v071 "knowledge-governance/internal/releaseadapter/v071" +) + +func maintenanceEnv() map[string]string { + return map[string]string{ + "LIBRARY_TARGET_INSTANCE": "kgmaint-weknora", + "LIBRARY_TARGET_INGRESS_URL": "http://127.0.0.1:18080/health", + "LIBRARY_TARGET_INGRESS_SERVICE": "ingress", + "LIBRARY_TARGET_INGRESS_DRAIN_FILE": "/tmp/gate/drain.json", + "LIBRARY_TARGET_REDIS_ADDR": "127.0.0.1:26379", + "LIBRARY_TARGET_REDIS_PASSWORD": "secret", + "LIBRARY_TARGET_REDIS_DB": "0", + } +} + +func captureSource() sourceConfig { + return sourceConfig{ + DatabaseURL: "postgres://kgmaint:pw@127.0.0.1:25432/kgmaint_target?sslmode=disable", + SearchIndex: "kgmaint_1028a_chunks", + Object: v071.S3ObjectStoreConfig{Provider: "minio", Bucket: "kgmaint-1028a"}, + } +} + +func captureOptions() options { + return options{project: "kgmaint-weknora", writers: "app", nonWriters: "postgres=database store", + dockerSocket: "/var/run/docker.sock", journalRoot: "/tmp/kgmaint-journal"} +} + +// 封存窗口必须是实例级:入口/队列来自与 worker 同一份 LIBRARY_TARGET_*, +// 库/索引/桶来自本次真实发现的源——两边混用就会"关 A 的门、读 B 的队列"。 +func TestCaptureMaintenanceConfigBindsGateQueueAndSourceIdentity(t *testing.T) { + env := maintenanceEnv() + config, err := captureMaintenanceConfig(captureOptions(), captureSource(), func(name string) string { return env[name] }) + if err != nil { + t.Fatalf("完整声明被拒:%v", err) + } + if config.Instance != "kgmaint-weknora" || config.Project != "kgmaint-weknora" { + t.Fatalf("实例/项目不对:%+v", config) + } + if config.IngressURL != "http://127.0.0.1:18080/health" || config.IngressService != "ingress" || + config.IngressDrainFile != "/tmp/gate/drain.json" { + t.Fatalf("入口闸门取值不对:%+v", config) + } + if config.RedisAddr != "127.0.0.1:26379" || config.RedisPassword != "secret" || config.RedisDB != 0 { + t.Fatalf("队列取值不对:%+v", config) + } + // 口令只进结构体,不落进任何可打印身份:身份里只有 host:port/db。 + if config.Database != "127.0.0.1:25432/kgmaint_target" { + t.Fatalf("数据库身份不对:%q", config.Database) + } + if strings.Contains(config.Database, "pw") { + t.Fatalf("数据库身份泄漏了口令:%q", config.Database) + } + if config.Index != "kgmaint_1028a_chunks" || config.Bucket != "kgmaint-1028a" { + t.Fatalf("索引/桶没取到源的身份:%+v", config) + } + if len(config.Writers) != 1 || config.Writers[0] != "app" || config.NonWriters["postgres"] == "" { + t.Fatalf("写入者声明没透传:%+v", config) + } + // 后台静默检查必须连"正在被封的源"。 + if config.DatabaseURL != captureSource().DatabaseURL { + t.Fatalf("后台静默检查连错库") + } +} + +// 没有闸门就没有排空证据:必须在开窗口之前拒绝,而不是退回"只锁库"的旧模式。 +func TestCaptureMaintenanceConfigRefusesWithoutIngress(t *testing.T) { + env := maintenanceEnv() + delete(env, "LIBRARY_TARGET_INGRESS_URL") + _, err := captureMaintenanceConfig(captureOptions(), captureSource(), func(name string) string { return env[name] }) + if err == nil || !strings.Contains(err.Error(), "LIBRARY_TARGET_INGRESS_URL") { + t.Fatalf("缺入口闸门没有被拒:%v", err) + } +} diff --git a/docs/library-capture.md b/docs/library-capture.md index 6a0dfa3..1a634e8 100644 --- a/docs/library-capture.md +++ b/docs/library-capture.md @@ -45,6 +45,28 @@ 同一个作业号重跑是**幂等重放**:直接返回已封存的同一份候选(`replay=true`、`source_read=false`、`writers_stopped=false`),不读来源、不停写入者;同一作业号换了 scope 或绑定库则明确拒绝。 +## 实例级维护接线(封存窗口的唯一入口证据) + +`capture` 与 `recover` 现在都按**实例级维护**开窗口,接线来自与 worker 同一组环境变量 +(名字只在 `internal/librarymaintenance/env.go` 出现一次): + +``` +LIBRARY_TARGET_INSTANCE 实例名(写进维护记录与目标指纹) +LIBRARY_TARGET_INGRESS_URL 入口地址(恢复后要能 200) +LIBRARY_TARGET_INGRESS_SERVICE Compose 里的入口闸门服务名 +LIBRARY_TARGET_INGRESS_DRAIN_FILE 闸门写排空证据的宿主机文件 +LIBRARY_TARGET_REDIS_ADDR/_PASSWORD/_DB 后台队列(asynq)地址 +``` + +为什么不再保留"库级窗口":被代理的 WeKnora 收到 SIGTERM 会直接 `exit(1)`,写者退出码 +永远不能当作"在途请求已排空"。排空证据只能在真正的入口上取——关闸门、等它把在途连接 +放完(`drain.json` 里的 `active_at_close` / `drained`)。因此缺少上面任何一项时 `capture` +会**在开窗口前拒绝**(报出缺少的变量名),而不是退回一个没有排空证据的窗口。 + +开窗口的顺序仍是:关入口并等在途排空 → 证明后台静默(稳定窗口 + 任务集合指纹)→ 停写入者 +→ 停后复核队列/指纹与入口 → 导出 → 恢复写入者与入口。停写者后仍会**再查一次**源是否结清, +所以窗口内派生的真实任务必须真的跑完,否则封存被拒(不是等一会儿就算了)。 + ## 从页面发起封存(主机侧 agent) 页面点「发起对内/对外封存」只是**投递一个作业**;执行发生在这台主机上的 agent: @@ -52,6 +74,10 @@ - `scripts/library-agent-install.sh` 构建并安装 `libraryagent`(systemd **user** 服务), 监听 `~/.local/share/knowledge-governance/agent/agent.sock`,目录 0700、socket 0600, 并额外用 `SO_PEERCRED` 要求对端与 agent 同 uid。 +- 维护接线由 `~/.local/share/knowledge-governance/agent.env`(0600,`EnvironmentFile=`)提供; + 没有这个文件安装脚本直接拒绝(`AGENT_ENV_MISSING`),免得装出一个"没有闸门"的 agent。 + 源容器/项目/写入者声明可用 `AGENT_SOURCE_APP`、`AGENT_SOURCE_PG`、`AGENT_SOURCE_ES`、 + `AGENT_PROJECT`、`AGENT_WRITERS`、`AGENT_NON_WRITERS` 覆盖,默认是本机 `weknora071full`。 - agent 自己固定 `libraryctl` 的路径、归档与日志根、Compose 项目与写入者声明; 请求只能选 `scope`(internal/external)和作业号,**不能**指定路径、项目、容器或命令。 - 只有 worker 通过挂载的 socket 目录投递作业(`LIBRARY_AGENT_SOCKET`)。容器里 diff --git a/internal/librarymaintenance/env.go b/internal/librarymaintenance/env.go new file mode 100644 index 0000000..1bb9d1b --- /dev/null +++ b/internal/librarymaintenance/env.go @@ -0,0 +1,91 @@ +package librarymaintenance + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// 目标入口闸门与后台队列的声明名称。 +// +// 为什么集中在这里:容器内的 worker 与主机侧的封存(cmd/libraryctl)都要讲 +// 同一份"这个实例的入口在哪、队列在哪";名字写两遍迟早会分叉,而分叉的表现 +// 是"一边关的是 A 实例的门、另一边读的是 B 实例的队列"——不会报错,只会给出 +// 错的证据。 +const ( + EnvInstance = "LIBRARY_TARGET_INSTANCE" + EnvIngressURL = "LIBRARY_TARGET_INGRESS_URL" + EnvIngressService = "LIBRARY_TARGET_INGRESS_SERVICE" + EnvIngressDrainFile = "LIBRARY_TARGET_INGRESS_DRAIN_FILE" + EnvRedisAddr = "LIBRARY_TARGET_REDIS_ADDR" + EnvRedisPassword = "LIBRARY_TARGET_REDIS_PASSWORD" + EnvRedisDB = "LIBRARY_TARGET_REDIS_DB" +) + +// TargetEnv 是一次实例级维护所需的"入口 + 队列"声明。 +// +// 缺任何一项都不许降级成"库级维护":没有闸门就没有"入口已关、在途已排空" +// 的证据,没有队列就证明不了停写者时后台是空的。这两条正是被代理的 WeKnora +// 收到 SIGTERM 就 exit(1) 之后唯一还能证明"请求没被截断"的东西。 +type TargetEnv struct { + Instance string + IngressURL string + IngressService string + IngressDrainFile string + RedisAddr string + RedisPassword string + RedisDB int +} + +// TargetFromEnv 解析维护目标声明。缺少的项一次性全部报出来(含变量名), +// 让部署方一次改对,而不是改一项再撞下一项。 +func TargetFromEnv(get func(string) string) (TargetEnv, error) { + if get == nil { + return TargetEnv{}, errors.New("缺少配置读取入口") + } + value := func(name string) string { return strings.TrimSpace(get(name)) } + target := TargetEnv{ + Instance: value(EnvInstance), + IngressURL: value(EnvIngressURL), + IngressService: value(EnvIngressService), + IngressDrainFile: value(EnvIngressDrainFile), + RedisAddr: value(EnvRedisAddr), + // 口令不 Trim:前后空格也是口令的一部分,Trim 会把能连上的配置变成连不上。 + RedisPassword: get(EnvRedisPassword), + } + missing := make([]string, 0, 5) + for _, item := range []struct { + name string + current string + }{ + {EnvInstance, target.Instance}, + {EnvIngressURL, target.IngressURL}, + {EnvIngressService, target.IngressService}, + {EnvIngressDrainFile, target.IngressDrainFile}, + {EnvRedisAddr, target.RedisAddr}, + } { + if item.current == "" { + missing = append(missing, item.name) + } + } + if strings.TrimSpace(target.RedisPassword) == "" { + missing = append(missing, EnvRedisPassword) + } + rawDB := value(EnvRedisDB) + switch { + case rawDB == "": + missing = append(missing, EnvRedisDB) + default: + db, err := strconv.Atoi(rawDB) + if err != nil || db < 0 { + return TargetEnv{}, fmt.Errorf("%s 不是有效的队列库号(%q)", EnvRedisDB, rawDB) + } + target.RedisDB = db + } + if len(missing) > 0 { + return TargetEnv{}, fmt.Errorf("实例级维护未接线:缺少 %s(没有入口闸门与队列就无法证明入口已排空、后台已静默)", + strings.Join(missing, "、")) + } + return target, nil +} diff --git a/internal/librarymaintenance/env_test.go b/internal/librarymaintenance/env_test.go new file mode 100644 index 0000000..f803977 --- /dev/null +++ b/internal/librarymaintenance/env_test.go @@ -0,0 +1,80 @@ +package librarymaintenance + +import ( + "strings" + "testing" +) + +func completeEnv() map[string]string { + return map[string]string{ + EnvInstance: "kgmaint-weknora", + EnvIngressURL: "http://127.0.0.1:18080/health", + EnvIngressService: "ingress", + EnvIngressDrainFile: "/tmp/gate/drain.json", + EnvRedisAddr: "127.0.0.1:26379", + EnvRedisPassword: "secret", + EnvRedisDB: "0", + } +} + +func lookup(values map[string]string) func(string) string { + return func(name string) string { return values[name] } +} + +func TestTargetFromEnvReadsTheWholeDeclaration(t *testing.T) { + target, err := TargetFromEnv(lookup(completeEnv())) + if err != nil { + t.Fatalf("完整声明被拒:%v", err) + } + if target.Instance != "kgmaint-weknora" || target.IngressService != "ingress" || + target.IngressURL != "http://127.0.0.1:18080/health" || target.IngressDrainFile != "/tmp/gate/drain.json" || + target.RedisAddr != "127.0.0.1:26379" || target.RedisPassword != "secret" || target.RedisDB != 0 { + t.Fatalf("解析结果不对:%+v", target) + } +} + +// 每个缺失项都必须点名报出来:部署方要一次改对,而不是"改一项再撞下一项"。 +func TestTargetFromEnvNamesEveryMissingItem(t *testing.T) { + for _, name := range []string{EnvInstance, EnvIngressURL, EnvIngressService, + EnvIngressDrainFile, EnvRedisAddr, EnvRedisPassword, EnvRedisDB} { + values := completeEnv() + delete(values, name) + if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("缺 %s 没有被点名:%v", name, err) + } + } +} + +// 空白不能当成"配了":空口令会让 queuers 连不上,却在日志里显示"已接线"。 +func TestTargetFromEnvRejectsBlankValues(t *testing.T) { + values := completeEnv() + values[EnvIngressURL] = " " + if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvIngressURL) { + t.Fatalf("空白入口地址没有被拒:%v", err) + } +} + +// 口令前后空格是口令的一部分:Trim 会把能连上的配置变成连不上。 +func TestTargetFromEnvKeepsPasswordWhitespace(t *testing.T) { + values := completeEnv() + values[EnvRedisPassword] = " pass with space " + target, err := TargetFromEnv(lookup(values)) + if err != nil { + t.Fatalf("带空格的口令被拒:%v", err) + } + if target.RedisPassword != " pass with space " { + t.Fatalf("口令被改写了:%q", target.RedisPassword) + } +} + +func TestTargetFromEnvRejectsInvalidRedisDB(t *testing.T) { + values := completeEnv() + values[EnvRedisDB] = "-1" + if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvRedisDB) { + t.Fatalf("负数库号没有被拒:%v", err) + } + values[EnvRedisDB] = "zero" + if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvRedisDB) { + t.Fatalf("非数字库号没有被拒:%v", err) + } +} diff --git a/scripts/library-agent-install.sh b/scripts/library-agent-install.sh index c533016..e600af1 100755 --- a/scripts/library-agent-install.sh +++ b/scripts/library-agent-install.sh @@ -9,8 +9,24 @@ set -euo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) UNIT_DIR="$HOME/.config/systemd/user" SOCKET_DIR="$HOME/.local/share/knowledge-governance/agent" +# 实例级维护接线(LIBRARY_TARGET_*)与源声明一起由这一个 0600 文件提供; +# 它属于"这台主机上的哪个实例",不属于任何一次请求。 +ENV_FILE="${AGENT_ENV_FILE:-$HOME/.local/share/knowledge-governance/agent.env}" +SOURCE_APP="${AGENT_SOURCE_APP:-WKNFull-app}" +SOURCE_PG="${AGENT_SOURCE_PG:-WKNFull-postgres}" +SOURCE_ES="${AGENT_SOURCE_ES:-WKNFull-elasticsearch}" +PROJECT="${AGENT_PROJECT:-weknora071full}" +WRITERS="${AGENT_WRITERS:-app}" +NON_WRITERS="${AGENT_NON_WRITERS:-frontend=UI only; no knowledge writes,mcp=read-only MCP server,dobotai-target=alpine placeholder; no knowledge writes,docreader=parse RPC service; app persists parse output,elasticsearch=search store; not an initiator,postgres=database store; not an initiator,redis=queue/cache store; not an initiator,neo4j=graph store; not an initiator,langfuse-web=LLM observability UI,langfuse-worker=LLM observability worker; own storage,langfuse-clickhouse=observability store,langfuse-minio=observability object store,langfuse-db-init=one-shot init; exited,searxng=read-only search proxy,searxng-init=one-shot init; exited}" mkdir -p "$UNIT_DIR" "$SOCKET_DIR" chmod 700 "$SOCKET_DIR" +if [ ! -f "$ENV_FILE" ]; then + # 宁可不装,也不要装出一个"没有闸门"的 agent:封存会在开窗口前被拒。 + echo "AGENT_ENV_MISSING: $ENV_FILE" >&2 + echo " write LIBRARY_TARGET_{INSTANCE,INGRESS_URL,INGRESS_SERVICE,INGRESS_DRAIN_FILE,REDIS_ADDR,REDIS_PASSWORD,REDIS_DB} there (0600)" >&2 + exit 1 +fi +chmod 600 "$ENV_FILE" echo "=== build libraryctl + libraryagent ===" (cd "$ROOT" && go build -o libraryctl ./cmd/libraryctl && go build -o libraryagent ./cmd/libraryagent) @@ -24,13 +40,18 @@ After=network.target [Service] Type=simple WorkingDirectory=$ROOT +EnvironmentFile=$ENV_FILE ExecStart=$ROOT/libraryagent \\ -socket $SOCKET_DIR/agent.sock \\ -binary $ROOT/libraryctl \\ -archive $HOME/.local/share/knowledge-governance/library \\ -journal $HOME/.local/share/knowledge-governance/freeze \\ - -project weknora071full \\ - -writers app + -source-app $SOURCE_APP \\ + -source-pg $SOURCE_PG \\ + -source-es $SOURCE_ES \\ + -project $PROJECT \\ + -writers $WRITERS \\ + -non-writers "$NON_WRITERS" Restart=always RestartSec=5