Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
10 KiB
Go
312 lines
10 KiB
Go
package agentwatch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeControl struct {
|
|
parsed int
|
|
configured int
|
|
failConfigure int
|
|
onParse func()
|
|
}
|
|
|
|
func (f *fakeControl) ParseCheck(ctx context.Context, _ string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
f.parsed++
|
|
if f.onParse != nil {
|
|
f.onParse()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeControl) Configure(ctx context.Context) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
f.configured++
|
|
if f.failConfigure > 0 {
|
|
f.failConfigure--
|
|
return errors.New("birdc unavailable")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func testConfig(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(root, "bird.d"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
main := filepath.Join(root, "bird.conf")
|
|
if err := os.WriteFile(main, []byte("router id 192.0.2.1;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fragment := filepath.Join(root, "bird.d", "peer.conf")
|
|
if err := os.WriteFile(fragment, []byte("# peer v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return root, fragment
|
|
}
|
|
|
|
func testWatcher(root string, ctl Control) *watcher {
|
|
return &watcher{ctl: ctl, cfg: Config{ActiveDir: root, Every: 30 * time.Second, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}}}
|
|
}
|
|
|
|
func TestHashConfigTreeTracksMainIncludesAndGlobMembership(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
first, err := hashConfigTree(context.Background(), root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(fragment, []byte("# peer v2\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := hashConfigTree(context.Background(), root)
|
|
if err != nil || second == first {
|
|
t.Fatalf("included edit not detected: digest=%x err=%v", second, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "bird.d", "manual.conf"), []byte("# manual\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
third, err := hashConfigTree(context.Background(), root)
|
|
if err != nil || third == second {
|
|
t.Fatalf("new glob member not detected: digest=%x err=%v", third, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("router id 192.0.2.2;\ninclude \"bird.d/*.conf\";\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fourth, err := hashConfigTree(context.Background(), root)
|
|
if err != nil || fourth == third {
|
|
t.Fatalf("main edit not detected: digest=%x err=%v", fourth, err)
|
|
}
|
|
}
|
|
|
|
func TestWatchSkipsUnchangedRetriesErrorsAndForcesReconciliation(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
ctl := &fakeControl{}
|
|
w := testWatcher(root, ctl)
|
|
now := time.Now()
|
|
if err := w.step(context.Background(), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.step(context.Background(), now.Add(30*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctl.configured != 1 || ctl.parsed != 1 {
|
|
t.Fatalf("unchanged tree configured again: %+v", ctl)
|
|
}
|
|
if err := os.WriteFile(fragment, []byte("# changed\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctl.failConfigure = 1
|
|
if err := w.step(context.Background(), now.Add(time.Minute)); err == nil {
|
|
t.Fatal("expected configure failure")
|
|
}
|
|
if !w.haveHash || ctl.configured != 2 {
|
|
t.Fatalf("failure changed remembered state: %+v", w)
|
|
}
|
|
if err := w.step(context.Background(), now.Add(90*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctl.configured != 3 {
|
|
t.Fatalf("failed change was not retried: %+v", ctl)
|
|
}
|
|
if err := w.step(context.Background(), now.Add(7*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctl.configured != 4 {
|
|
t.Fatalf("forced reconciliation missing: %+v", ctl)
|
|
}
|
|
// A fresh watcher must reconcile once even when another process already loaded the same bytes.
|
|
restarted := testWatcher(root, ctl)
|
|
if err := restarted.step(context.Background(), now.Add(8*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctl.configured != 5 {
|
|
t.Fatalf("restart did not reconcile: %+v", ctl)
|
|
}
|
|
}
|
|
|
|
func TestWatchRejectsChangingTreeBeforeConfigure(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
ctl := &fakeControl{onParse: func() { _ = os.WriteFile(fragment, []byte("# raced\n"), 0o644) }}
|
|
w := testWatcher(root, ctl)
|
|
if err := w.step(context.Background(), time.Now()); err == nil || !strings.Contains(err.Error(), "changed during parse check") {
|
|
t.Fatalf("want race retry, got %v", err)
|
|
}
|
|
if ctl.configured != 0 {
|
|
t.Fatal("configured a tree that changed after parse check")
|
|
}
|
|
ctl.onParse = nil
|
|
if err := w.step(context.Background(), time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctl.configured != 1 {
|
|
t.Fatal("stable tree was not configured")
|
|
}
|
|
}
|
|
|
|
func TestUnsupportedIncludeUsesLegacyEveryTickCadence(t *testing.T) {
|
|
root, _ := testConfig(t)
|
|
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include variable;\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctl := &fakeControl{}
|
|
w := testWatcher(root, ctl)
|
|
now := time.Now()
|
|
for i := 0; i < 3; i++ {
|
|
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if ctl.parsed != 3 || ctl.configured != 3 || w.haveHash {
|
|
t.Fatalf("unsupported tree was skipped: %+v", ctl)
|
|
}
|
|
}
|
|
|
|
func TestMissingIncludedFileFallsBackButMissingMainWaits(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/peer.conf\";\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(fragment); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctl := &fakeControl{}
|
|
w := testWatcher(root, ctl)
|
|
now := time.Now()
|
|
for i := 0; i < 2; i++ {
|
|
if err := w.step(context.Background(), now.Add(time.Duration(i)*30*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if ctl.configured != 2 {
|
|
t.Fatalf("missing include skipped legacy cadence: %+v", ctl)
|
|
}
|
|
if err := os.Remove(filepath.Join(root, "bird.conf")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.step(context.Background(), now.Add(time.Minute)); !errors.Is(err, errMainNotReady) {
|
|
t.Fatalf("missing main should wait, got %v", err)
|
|
}
|
|
if ctl.configured != 2 {
|
|
t.Fatal("configured without main bird.conf")
|
|
}
|
|
}
|
|
|
|
func TestHashConfigTreeBoundsAndEscapes(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
if err := os.WriteFile(fragment, make([]byte, maxFileBytes+1), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
|
t.Fatalf("want size limit, got %v", err)
|
|
}
|
|
if err := os.WriteFile(fragment, []byte("# small\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "outside.conf")
|
|
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \""+filepath.ToSlash(outside)+"\";\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "escapes active directory") {
|
|
t.Fatalf("want outside include rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHashConfigTreeRejectsSymlinkInclude(t *testing.T) {
|
|
root, fragment := testConfig(t)
|
|
if err := os.Remove(fragment); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "outside.conf")
|
|
if err := os.WriteFile(outside, []byte("# outside\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, fragment); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
|
t.Fatalf("want symlink rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIncludeCommentsAndUnsupportedForms(t *testing.T) {
|
|
patterns, err := includePatterns("# include \"ignored.conf\";\n/*\ninclude \"also-ignored.conf\";\n*/\ninclude \"bird.d/*.conf\";\n")
|
|
if err != nil || len(patterns) != 1 || patterns[0] != "bird.d/*.conf" {
|
|
t.Fatalf("comment handling failed: patterns=%v err=%v", patterns, err)
|
|
}
|
|
if _, err := includePatterns("if true then include \"dynamic.conf\";\n"); err == nil {
|
|
t.Fatal("inline include should fall back to legacy cadence")
|
|
}
|
|
if _, err := includePatterns("include \"bird.d/[!a].conf\";\n"); err != nil {
|
|
// Bracket glob is rejected by the scanner after lexical parsing.
|
|
t.Fatalf("literal include statement should parse before glob validation: %v", err)
|
|
}
|
|
root, _ := testConfig(t)
|
|
if err := os.WriteFile(filepath.Join(root, "bird.conf"), []byte("include \"bird.d/[!a].conf\";\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := hashConfigTree(context.Background(), root); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
|
t.Fatalf("bracket glob should fall back, got %v", err)
|
|
}
|
|
if _, err := includePatterns("define note = \"single\\\";\ninclude \"bird.d/real.conf\";\n"); err == nil {
|
|
t.Fatal("backslash in quoted text must force conservative fallback")
|
|
}
|
|
}
|
|
|
|
func TestBoundedGlobLimitsMatches(t *testing.T) {
|
|
root := t.TempDir()
|
|
for i := 0; i <= maxConfigFiles; i++ {
|
|
name := filepath.Join(root, fmt.Sprintf("peer-%03d.conf", i))
|
|
if err := os.WriteFile(name, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := boundedGlob(filepath.Join(root, "*.conf")); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
|
t.Fatalf("want bounded glob rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadBoundedStopsAfterLimit(t *testing.T) {
|
|
r := strings.NewReader("abcdefghij")
|
|
if _, err := readBounded(r, 3); err == nil {
|
|
t.Fatal("growing file exceeded limit")
|
|
}
|
|
if got := r.Len(); got != 6 {
|
|
t.Fatalf("reader consumed beyond limit+1: %d bytes remain", got)
|
|
}
|
|
}
|
|
|
|
func TestRunStopsOnCancellation(t *testing.T) {
|
|
root, _ := testConfig(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ctl := &fakeControl{}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- Run(ctx, ctl, Config{ActiveDir: root, Every: time.Hour, ReconcileEvery: 5 * time.Minute, CommandTimeout: time.Second, Logf: func(string, ...any) {}})
|
|
}()
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watch did not stop after cancellation")
|
|
}
|
|
}
|