47eb23cf68
页面上的"可发起封存"原来只判 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 带原因;重启后恢复。
185 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|