bb64c32654
build / build (push) Failing after 16s
- R4:封存子进程 SIGTERM 宽限停、libraryctl 信号转 ctx;失败/超时后 agent 再做 有界 recover,恢复失败即降级阻断封存并给出原因;启动先恢复、关闭等恢复完成 - R5:封存闸口以已存 payload 为准,同作业号换范围 409 且不入新队;重放回显真实状态 - R6:作业列表按类型查询(ListByType),不再先取全局再过滤 - R7:根目录 libraryagent 二进制移出版本库,保留运行产物并精确忽略 - UI:发布管理只保留对内/对外两目标,整合快照/完整性/作业/存储;链路未接线项禁用 并标原因;整库版本独立导航并入;文档级旧流程折叠保留
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package libraryagent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testAgent(t *testing.T) (*Agent, string, func() []string) {
|
|
t.Helper()
|
|
root, err := os.MkdirTemp(os.TempDir(), "kgag-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(root) })
|
|
if err = os.Chmod(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var calls []string
|
|
config := Config{
|
|
Socket: filepath.Join(root, "agent.sock"),
|
|
Binary: "/bin/echo",
|
|
ArchiveRoot: filepath.Join(root, "library"),
|
|
JournalRoot: filepath.Join(root, "freeze"),
|
|
SourceApp: "WKNFull-app",
|
|
SourcePG: "WKNFull-postgres",
|
|
SourceES: "WKNFull-elasticsearch",
|
|
GovernancePG: "knowledge-governance-p1-postgres-1",
|
|
Project: "weknora071full",
|
|
Writers: "app",
|
|
NonWriters: "frontend=UI only; no knowledge writes",
|
|
MaxDuration: time.Minute,
|
|
Run: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
mu.Lock()
|
|
calls = append(calls, name+" "+strings.Join(args, " "))
|
|
mu.Unlock()
|
|
return []byte(`{"snapshot_id":"abc","state":"succeeded"}`), nil
|
|
},
|
|
}
|
|
agent, err := New(config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return agent, config.Socket, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), calls...)
|
|
}
|
|
}
|
|
|
|
func roundTrip(t *testing.T, socket string, payload string) response {
|
|
t.Helper()
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
if _, err = conn.Write([]byte(payload + "\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out response
|
|
if err = json.NewDecoder(conn).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The socket is the whole security boundary: 0600 in a 0700 directory, and the
|
|
// declaration (archive, journal, project, writers) comes from the agent's own
|
|
// configuration, never from the request.
|
|
func TestAgentSocketIsPrivateAndPinsTheDeclaration(t *testing.T) {
|
|
agent, socket, calls := testAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("agent socket never appeared")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
info, err := os.Stat(socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0600 {
|
|
t.Fatalf("socket mode %v", info.Mode().Perm())
|
|
}
|
|
dir, err := os.Stat(filepath.Dir(socket))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dir.Mode().Perm() != 0700 {
|
|
t.Fatalf("socket directory mode %v", dir.Mode().Perm())
|
|
}
|
|
out := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-internal-test"}`)
|
|
if !out.OK || out.Job != "lib-internal-test" {
|
|
t.Fatalf("capture rejected: %+v", out)
|
|
}
|
|
recorded := captureCalls(calls())
|
|
if len(recorded) != 1 {
|
|
t.Fatalf("expected one capture execution, got %v", recorded)
|
|
}
|
|
for _, want := range []string{"capture -scope internal -job lib-internal-test",
|
|
"-archive " + filepath.Join(filepath.Dir(socket), "library"),
|
|
"-journal " + filepath.Join(filepath.Dir(socket), "freeze"),
|
|
"-project weknora071full", "-writers app"} {
|
|
if !strings.Contains(recorded[0], want) {
|
|
t.Fatalf("execution %q missing %q", recorded[0], want)
|
|
}
|
|
}
|
|
if strings.Contains(recorded[0], "..") {
|
|
t.Fatal("unexpected path traversal in declaration")
|
|
}
|
|
}
|
|
|
|
func TestAgentRejectsUnsupportedRequestsWithoutExecuting(t *testing.T) {
|
|
agent, socket, calls := testAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("agent socket never appeared")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
for _, payload := range []string{
|
|
`{"action":"shell","command":"docker ps"}`,
|
|
`{"action":"capture","scope":"production"}`,
|
|
`{"action":"capture","scope":"internal","job":"bad job; rm -rf /"}`,
|
|
`not json`,
|
|
} {
|
|
out := roundTrip(t, socket, payload)
|
|
if out.OK || out.Error == "" {
|
|
t.Fatalf("accepted %q: %+v", payload, out)
|
|
}
|
|
}
|
|
if got := captureCalls(calls()); len(got) != 0 {
|
|
t.Fatalf("rejected requests still executed: %v", got)
|
|
}
|
|
}
|
|
|
|
func captureCalls(calls []string) []string {
|
|
kept := make([]string, 0, len(calls))
|
|
for _, call := range calls {
|
|
if strings.Contains(call, " capture ") {
|
|
kept = append(kept, call)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
func recoverCalls(calls []string) []string {
|
|
kept := make([]string, 0, len(calls))
|
|
for _, call := range calls {
|
|
if strings.Contains(call, " recover ") {
|
|
kept = append(kept, call)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// failingAgent builds an agent whose capture always fails and whose recovery
|
|
// outcome can be switched, to prove the recovery responsibility after a
|
|
// killed/aborted child.
|
|
func failingAgent(t *testing.T) (*Agent, string, func() []string, func(error)) {
|
|
t.Helper()
|
|
root, err := os.MkdirTemp(os.TempDir(), "kgrf-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(root) })
|
|
if err = os.Chmod(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var calls []string
|
|
var recoverErr error
|
|
agent, err := New(Config{
|
|
Socket: filepath.Join(root, "agent.sock"), Binary: "/bin/echo",
|
|
ArchiveRoot: filepath.Join(root, "library"), JournalRoot: filepath.Join(root, "freeze"),
|
|
SourceApp: "app-container", SourcePG: "pg", SourceES: "es", GovernancePG: "gov",
|
|
Project: "weknora071full", Writers: "app", NonWriters: "frontend=UI only",
|
|
MaxDuration: time.Minute, StopGrace: time.Minute, RecoverTimeout: time.Minute,
|
|
Run: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
mu.Lock()
|
|
calls = append(calls, name+" "+strings.Join(args, " "))
|
|
current := recoverErr
|
|
mu.Unlock()
|
|
if len(args) > 0 && args[0] == "recover" {
|
|
if current != nil {
|
|
return []byte("writer restart failed"), current
|
|
}
|
|
return []byte(`{"recovered":true}`), nil
|
|
}
|
|
return []byte("LIBRARYCTL_FAILED: stop outcome uncertain"), errors.New("exit status 1")
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return agent, filepath.Join(root, "agent.sock"), func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), calls...)
|
|
}, func(err error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
recoverErr = err
|
|
}
|
|
}
|
|
|
|
// A failed capture must be followed by a recovery attempt, not just an error.
|
|
func TestAgentRecoversWritersAfterFailedCapture(t *testing.T) {
|
|
agent, socket, calls, _ := failingAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
waitSocket(t, socket)
|
|
out := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-fail-1"}`)
|
|
if out.OK {
|
|
t.Fatalf("failed capture reported success: %+v", out)
|
|
}
|
|
if !strings.Contains(out.Error, "已执行 recover 并确认写入者恢复") {
|
|
t.Fatalf("error does not state the recovery outcome: %q", out.Error)
|
|
}
|
|
if len(recoverCalls(calls())) == 0 {
|
|
t.Fatal("no recovery ran after the failed capture")
|
|
}
|
|
}
|
|
|
|
// If recovery itself fails, the boundary must refuse further captures instead of
|
|
// leaving the source stopped and merely marking a job failed.
|
|
func TestAgentBlocksCapturesWhenRecoveryFails(t *testing.T) {
|
|
agent, socket, calls, setRecoverErr := failingAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
waitSocket(t, socket)
|
|
setRecoverErr(errors.New("docker restart failed"))
|
|
out := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-fail-2"}`)
|
|
if out.OK || !strings.Contains(out.Error, "写入者恢复失败") || !strings.Contains(out.Error, "已阻断后续封存") {
|
|
t.Fatalf("recovery failure not surfaced: %+v", out)
|
|
}
|
|
before := len(captureCalls(calls()))
|
|
blocked := roundTrip(t, socket, `{"action":"capture","scope":"external","job":"lib-fail-3"}`)
|
|
if blocked.OK || !strings.Contains(blocked.Error, "降级") {
|
|
t.Fatalf("degraded agent accepted a capture: %+v", blocked)
|
|
}
|
|
if got := len(captureCalls(calls())); got != before {
|
|
t.Fatalf("blocked capture still executed (%d -> %d)", before, got)
|
|
}
|
|
// A verified recovery clears the degraded state and lets work resume.
|
|
setRecoverErr(nil)
|
|
if recovered := roundTrip(t, socket, `{"action":"recover"}`); !recovered.OK {
|
|
t.Fatalf("explicit recovery failed: %+v", recovered)
|
|
}
|
|
if resumed := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-fail-4"}`); strings.Contains(resumed.Error, "降级") {
|
|
t.Fatalf("recovery did not clear the degraded state: %+v", resumed)
|
|
}
|
|
}
|
|
|
|
// The child is stopped with SIGTERM and the grace period, not an immediate kill.
|
|
func TestRunStopsChildWithSigterm(t *testing.T) {
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "child.sh")
|
|
// A real child that only exits 0 when it receives SIGTERM.
|
|
child := "#!/bin/sh\ntrap 'exit 0' TERM\nwhile :; do sleep 0.05; done\n"
|
|
if err := os.WriteFile(script, []byte(child), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent, err := New(Config{Socket: "/tmp/irrelevant.sock", Binary: script,
|
|
ArchiveRoot: filepath.Join(dir, "library"), JournalRoot: filepath.Join(dir, "freeze"),
|
|
SourceApp: "app-container", SourcePG: "pg", SourceES: "es", GovernancePG: "gov",
|
|
Project: "weknora071full", Writers: "app", NonWriters: "frontend=UI only",
|
|
MaxDuration: 2 * time.Second, StopGrace: 5 * time.Second, RecoverTimeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
|
defer cancel()
|
|
started := time.Now()
|
|
_, err = agent.config.run(ctx, script)
|
|
if err == nil {
|
|
t.Fatal("cancelled run reported success")
|
|
}
|
|
if elapsed := time.Since(started); elapsed > 4*time.Second {
|
|
t.Fatalf("child was not stopped within the graceful window: %s", elapsed)
|
|
}
|
|
if ctx.Err() == nil {
|
|
t.Fatal("test did not exercise cancellation")
|
|
}
|
|
}
|
|
|
|
func waitSocket(t *testing.T, socket string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("agent socket never appeared")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsIncompleteConfiguration(t *testing.T) {
|
|
if _, err := New(Config{}); err == nil {
|
|
t.Fatal("empty configuration accepted")
|
|
}
|
|
if _, err := New(Config{Socket: "relative.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
|
|
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend=ui", MaxDuration: time.Minute}); err == nil {
|
|
t.Fatal("relative socket path accepted")
|
|
}
|
|
if _, err := New(Config{Socket: "/tmp/a.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
|
|
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend", MaxDuration: time.Minute}); err == nil {
|
|
t.Fatal("non-writer declaration without a reason accepted")
|
|
}
|
|
}
|