库级窗口永远拿不到在途排空证据:被代理的 WeKnora 收到 SIGTERM 就 exit(1), 写者退出码不能当作请求已排空。capture/recover 改由 librarymaintenance.Manager 装配入口闸门、后台静默检查、目标指纹与入口探针(与发布链路同一套实现), 排空证据来自闸门 drain 记录;缺 LIBRARY_TARGET_* 时在开窗口前拒绝并点名。 维护接线解析收敛到 internal/librarymaintenance/env.go,agent 与 worker 讲同一组 变量名;agent 启动如实打印接线状态;安装脚本强制 0600 agent.env。 试过:make verify 15 项全绿;新增 7 个用例(接线解析/身份绑定/缺闸门拒绝)。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+93
-10
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`)。容器里
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user