Files
knowledge-governance/internal/httpapi/library_executor_test.go
T
姚章浩 47eb23cf68 feat(library): 封存执行器可用性与还原条件来自真实观测
页面上的"可发起封存"原来只判 LIBRARY_AGENT_SOCKET 非空:正式栈的 API 根本没挂
那个 socket,而 agent 曾顶着正式单元名冻结候选实例——配置非空、页面可点、真发起
却封错库。

现在:agent 新增 health 动作(不占封存操作锁、不跑子进程,只回答身份/降级/忙),
worker 每 30 秒探测并写入迁移 074 的 library_executor_status;overview、快照列表与
POST /library/captures 共用同一份判定(过期、不可达、实例不符、降级、正忙都拒绝并
给原因)。封存结果由 agent 附上自报身份,worker 在停写入者之前再探测一次、执行后
用结果复核,不一致就不登记测试封存——心跳只能证明"刚才"。

期望身份用 LIBRARY_SOURCE_*(封存源),不是 LIBRARY_TARGET_*(同步目标),否则
正确的执行器会被判成不匹配。另加 GET /library/restore-availability:恢复能否执行由
双闸、该快照的恢复授权与实例维护状态决定,页面不再写死 disabled。

真跑(候选隔离栈,两处 socket 隔离不变):overview 返回 executor
{kgmaint-weknora/kgmaint-app};停 agent 后转不可达并 503 带原因;重启后恢复。
2026-10-29 13:01:37 +08:00

185 lines
6.9 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/libraryexecutor"
)
// fakeJobStore 只是"存储"替身;执行器状态同样是它的一条记录。
func (s *fakeJobStore) LibraryExecutorStatus(_ context.Context, instance string) (*libraryexecutor.Observation, error) {
if s.executorStatus == nil || s.executorStatus.Instance != instance {
return nil, nil
}
return s.executorStatus, nil
}
// healthyExecutor 返回一份"worker 刚观测到、指向本实例"的记录。
func healthyExecutor(now time.Time) *libraryexecutor.Observation {
return &libraryexecutor.Observation{
Instance: "kgmaint-weknora", Reachable: true, ObservedAt: now,
Identity: libraryexecutor.Identity{Project: "kgmaint-weknora", SourceApp: "kgmaint-app",
ArchiveRoot: "/tmp/kg-e2e/archive", Writers: "app"},
}
}
// captureReadyConfig 是"执行器真的可用"时的配置:实例、期望项目与观测三者一致。
func captureReadyConfig(store *fakeJobStore) Config {
store.executorStatus = healthyExecutor(time.Now().UTC())
return Config{
Store: store,
LibraryAgentSocket: "/run/library-agent/agent.sock",
LibrarySourceInstance: "kgmaint-weknora",
LibrarySourceProject: "kgmaint-weknora",
LibraryArchiveRoot: "/var/lib/knowledge-governance/library",
}
}
func decodeCaptureRefusal(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) {
t.Helper()
var body struct {
Code string `json:"code"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("body %s: %v", recorder.Body.String(), err)
}
return body.Code, body.Reason
}
// 页面上的"可发起"必须等于接口真的会接受:配置了 socket 但执行器不可达、
// 指向别的实例、降级或过期时,都必须带着原因拒绝,而不是先排队再失败。
func TestLibraryCaptureRefusesWhenExecutorIsNotVerified(t *testing.T) {
now := time.Now().UTC()
tests := []struct {
name string
config func(*Config)
text string
}{
{name: "no instance identity", config: func(c *Config) {
c.LibrarySourceInstance = ""
}, text: "LIBRARY_SOURCE_INSTANCE"},
{name: "nothing reported yet", config: func(c *Config) {
c.Store.(*fakeJobStore).executorStatus = nil
}, text: "还没有上报状态"},
{name: "unreachable", config: func(c *Config) {
c.Store.(*fakeJobStore).executorStatus.Reachable = false
c.Store.(*fakeJobStore).executorStatus.Error = "library agent is unreachable"
}, text: "不可达"},
{name: "stale observation", config: func(c *Config) {
c.Store.(*fakeJobStore).executorStatus.ObservedAt = now.Add(-10 * time.Minute)
}, text: "已过期"},
{name: "points at another instance", config: func(c *Config) {
c.Store.(*fakeJobStore).executorStatus.Identity.Project = "weknora071full"
}, text: "指向的实例不匹配"},
{name: "degraded", config: func(c *Config) {
c.Store.(*fakeJobStore).executorStatus.Degraded = "写入者恢复失败"
}, text: "降级"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store := &fakeJobStore{}
config := captureReadyConfig(store)
test.config(&config)
recorder := httptest.NewRecorder()
libraryCaptureEnqueue(recorder, captureRequest(`{"scope":"internal"}`), config)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
code, reason := decodeCaptureRefusal(t, recorder)
if code != "library_capture_unavailable" {
t.Fatalf("code=%q body=%s", code, recorder.Body.String())
}
if reason == "" || !strings.Contains(reason, test.text) {
t.Fatalf("reason=%q want it to mention %q", reason, test.text)
}
if store.enqueued.Type != "" {
t.Fatal("refused capture still enqueued a job")
}
})
}
}
// The overview must report the same reason the capture endpoint would use.
func TestLibraryOverviewReportsExecutorIdentityAndReason(t *testing.T) {
store := &fakeLibraryStore{}
config := captureReadyConfig(&store.fakeJobStore)
// libraryJobStore 的方法在 fakeLibraryStore 上,执行器状态在嵌入的 fakeJobStore 上。
config.Store = store
recorder := httptest.NewRecorder()
libraryOverviewHandler(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/library/overview?scope=internal", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var body struct {
CaptureAvailable bool `json:"capture_available"`
Executor struct {
Available bool `json:"available"`
Instance string `json:"instance"`
Project string `json:"project"`
SourceApp string `json:"source_app"`
ArchiveRoot string `json:"archive_root"`
} `json:"executor"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.CaptureAvailable || !body.Executor.Available {
t.Fatalf("overview did not report an available executor: %s", recorder.Body.String())
}
if body.Executor.Project != "kgmaint-weknora" || body.Executor.SourceApp != "kgmaint-app" ||
body.Executor.ArchiveRoot != "/tmp/kg-e2e/archive" || body.Executor.Instance != "kgmaint-weknora" {
t.Fatalf("executor view = %+v", body.Executor)
}
store.fakeJobStore.executorStatus.Identity.Project = "weknora071full"
recorder = httptest.NewRecorder()
libraryOverviewHandler(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/library/overview?scope=internal", nil), config)
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.CaptureAvailable {
t.Fatalf("overview still says available after the executor moved to another instance: %s", recorder.Body.String())
}
}
// "恢复至该版本"必须由真实条件决定:没有 restore 授权就明确说出来。
func TestLibraryRestoreAvailabilityExplainsMissingAuthorization(t *testing.T) {
store := &fakeJobStore{}
config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true}
recorder := httptest.NewRecorder()
libraryRestoreAvailability(recorder, httptest.NewRequest(http.MethodGet,
"/api/v1/library/restore-availability?scope=internal&snapshot_id=snap-1", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var body struct {
Allowed bool `json:"allowed"`
Reasons []string `json:"reasons"`
Authorization struct {
State string `json:"state"`
} `json:"authorization"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Allowed || body.Authorization.State != "none" || len(body.Reasons) == 0 {
t.Fatalf("body=%s", recorder.Body.String())
}
found := false
for _, reason := range body.Reasons {
if strings.Contains(reason, "恢复授权") {
found = true
}
}
if !found {
t.Fatalf("reasons = %v", body.Reasons)
}
}