feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
924f111e48
commit
f8e0520817
197 files changed
+18655
-1416
No files matched your search
@@ -0,0 +1,312 @@
|
||||
package agentwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConfigFiles = 512
|
||||
maxConfigBytes = 64 << 20
|
||||
maxFileBytes = 16 << 20
|
||||
maxIncludeDepth = 16
|
||||
maxDirectoryEntries = 4096
|
||||
)
|
||||
|
||||
var errMainNotReady = errors.New("main bird.conf is not ready")
|
||||
|
||||
// Only standalone literal include statements are hashable. Other valid BIRD
|
||||
// grammar falls back to the legacy configure-on-every-tick path.
|
||||
var includeLine = regexp.MustCompile(`(?m)^[\t ]*include[\t ]+"([^"\r\n]+)"[\t ]*;[\t ]*(?:#[^\r\n]*)?\r?$`)
|
||||
|
||||
type configScanner struct {
|
||||
ctx context.Context
|
||||
root string
|
||||
h hash.Hash
|
||||
seen map[string]bool
|
||||
files int
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func hashConfigTree(ctx context.Context, activeDir string) ([sha256.Size]byte, error) {
|
||||
var zero [sha256.Size]byte
|
||||
root, err := filepath.Abs(activeDir)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("agentwatch: active directory: %w", err)
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return zero, fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
|
||||
}
|
||||
return zero, fmt.Errorf("agentwatch: resolve active directory: %w", err)
|
||||
}
|
||||
s := &configScanner{ctx: ctx, root: filepath.Clean(root), h: sha256.New(), seen: make(map[string]bool)}
|
||||
if err := s.visit(filepath.Join(s.root, "bird.conf"), 0); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], s.h.Sum(nil))
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (s *configScanner) visit(path string, depth int) error {
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if depth > maxIncludeDepth {
|
||||
return fmt.Errorf("agentwatch: include depth exceeds %d", maxIncludeDepth)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("agentwatch: include %q escapes active directory", path)
|
||||
}
|
||||
if s.seen[path] {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if depth == 0 && errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("agentwatch: %w: %v", errMainNotReady, err)
|
||||
}
|
||||
return fmt.Errorf("agentwatch: inspect %q: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("agentwatch: include %q is not a regular file", path)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: resolve %q: %w", path, err)
|
||||
}
|
||||
if _, err := s.withinRoot(resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxFileBytes {
|
||||
return fmt.Errorf("agentwatch: include %q exceeds %d bytes", path, maxFileBytes)
|
||||
}
|
||||
if s.files >= maxConfigFiles || s.bytes+info.Size() > maxConfigBytes {
|
||||
return fmt.Errorf("agentwatch: configuration tree exceeds %d files or %d bytes", maxConfigFiles, maxConfigBytes)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: open %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
limit := int64(maxFileBytes)
|
||||
if remaining := int64(maxConfigBytes) - s.bytes; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
content, err := readBounded(f, limit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: read %q: %w", path, err)
|
||||
}
|
||||
s.seen[path] = true
|
||||
s.files++
|
||||
s.bytes += int64(len(content))
|
||||
writeHash(s.h, filepath.ToSlash(rel), content)
|
||||
patterns, err := includePatterns(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: %s: %w", rel, err)
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if strings.ContainsAny(pattern, `[\`) {
|
||||
return fmt.Errorf("agentwatch: unsupported escaped include %q", pattern)
|
||||
}
|
||||
candidate := pattern
|
||||
if !filepath.IsAbs(candidate) {
|
||||
candidate = filepath.Join(filepath.Dir(path), filepath.FromSlash(candidate))
|
||||
}
|
||||
candidate = filepath.Clean(candidate)
|
||||
// The pattern itself changes the digest even when its glob currently matches nothing.
|
||||
writeHash(s.h, "include", []byte(filepath.ToSlash(candidate)))
|
||||
if _, err := s.withinRoot(candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.ContainsAny(candidate, "*?") {
|
||||
if strings.ContainsAny(filepath.Dir(candidate), "*?") {
|
||||
return fmt.Errorf("agentwatch: unsupported include directory glob %q", pattern)
|
||||
}
|
||||
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(candidate))
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: resolve include directory %q: %w", pattern, err)
|
||||
}
|
||||
if _, err := s.withinRoot(resolvedDir); err != nil {
|
||||
return err
|
||||
}
|
||||
matches, err := boundedGlob(candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentwatch: include glob %q: %w", pattern, err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
if err := s.visit(match, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.visit(candidate, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBounded(r io.Reader, limit int64) ([]byte, error) {
|
||||
content, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > limit {
|
||||
return nil, fmt.Errorf("configuration tree changed or exceeded size limit")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// boundedGlob only handles * and ? in a filename. Broader BIRD glob grammar
|
||||
// falls back to every-tick configure instead of risking a false unchanged hash.
|
||||
func boundedGlob(pattern string) ([]string, error) {
|
||||
dir, base := filepath.Dir(pattern), filepath.Base(pattern)
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
names, err := f.Readdirnames(maxDirectoryEntries + 1)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if len(names) > maxDirectoryEntries {
|
||||
return nil, fmt.Errorf("directory exceeds %d entries", maxDirectoryEntries)
|
||||
}
|
||||
matches := make([]string, 0)
|
||||
for _, name := range names {
|
||||
match, err := filepath.Match(base, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
matches = append(matches, filepath.Join(dir, name))
|
||||
if len(matches) > maxConfigFiles {
|
||||
return nil, fmt.Errorf("glob exceeds %d matches", maxConfigFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *configScanner) withinRoot(path string) (string, error) {
|
||||
rel, err := filepath.Rel(s.root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("agentwatch: include %q escapes active directory", path)
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func writeHash(h hash.Hash, name string, content []byte) {
|
||||
var size [8]byte
|
||||
binary.LittleEndian.PutUint64(size[:], uint64(len(name)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write([]byte(name))
|
||||
binary.LittleEndian.PutUint64(size[:], uint64(len(content)))
|
||||
_, _ = h.Write(size[:])
|
||||
_, _ = h.Write(content)
|
||||
}
|
||||
|
||||
func includePatterns(content string) ([]string, error) {
|
||||
tokens, unsupportedQuotedText := includeTokenOffsets(content)
|
||||
if unsupportedQuotedText {
|
||||
return nil, fmt.Errorf("unsupported quoted text or escape")
|
||||
}
|
||||
tokenSet := make(map[int]bool, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = true
|
||||
}
|
||||
var patterns []string
|
||||
for _, match := range includeLine.FindAllStringSubmatchIndex(content, -1) {
|
||||
keyword := match[0] + strings.Index(content[match[0]:match[1]], "include")
|
||||
if !tokenSet[keyword] {
|
||||
continue
|
||||
} // text inside a block comment
|
||||
delete(tokenSet, keyword)
|
||||
patterns = append(patterns, content[match[2]:match[3]])
|
||||
}
|
||||
if len(tokenSet) != 0 {
|
||||
return nil, fmt.Errorf("unsupported include syntax")
|
||||
}
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
// Recognize include keywords outside BIRD comments and quoted strings.
|
||||
func includeTokenOffsets(config string) ([]int, bool) {
|
||||
var offsets []int
|
||||
quote := byte(0)
|
||||
lineComment, blockComment := false, false
|
||||
for i := 0; i < len(config); {
|
||||
c := config[i]
|
||||
if lineComment {
|
||||
if c == '\n' {
|
||||
lineComment = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if blockComment {
|
||||
if c == '*' && i+1 < len(config) && config[i+1] == '/' {
|
||||
blockComment = false
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
if c == '\\' {
|
||||
return offsets, true
|
||||
}
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '#' || (c == '/' && i+1 < len(config) && config[i+1] == '/') {
|
||||
lineComment = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '/' && i+1 < len(config) && config[i+1] == '*' {
|
||||
blockComment = true
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
quote = c
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
|
||||
start := i
|
||||
for i < len(config) && ((config[i] >= 'a' && config[i] <= 'z') || (config[i] >= 'A' && config[i] <= 'Z') || (config[i] >= '0' && config[i] <= '9') || config[i] == '_') {
|
||||
i++
|
||||
}
|
||||
if config[start:i] == "include" {
|
||||
offsets = append(offsets, start)
|
||||
}
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return offsets, quote != 0 || blockComment
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package agentwatch reloads BIRD when its active configuration tree changes.
|
||||
package agentwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Control is the subset of BIRD operations required by the watch loop.
|
||||
type Control interface {
|
||||
ParseCheck(context.Context, string) error
|
||||
Configure(context.Context) error
|
||||
}
|
||||
|
||||
// Config sets bounded polling and reconciliation behavior.
|
||||
type Config struct {
|
||||
ActiveDir string
|
||||
Every time.Duration
|
||||
ReconcileEvery time.Duration
|
||||
CommandTimeout time.Duration
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
ctl Control
|
||||
cfg Config
|
||||
hash [sha256.Size]byte
|
||||
haveHash bool
|
||||
lastConfigured time.Time
|
||||
}
|
||||
|
||||
// Run starts with one check, then polls until ctx is cancelled. A successful
|
||||
// birdc exit advances the remembered hash; it is not proof of BIRD ACK.
|
||||
func Run(ctx context.Context, ctl Control, cfg Config) error {
|
||||
if ctl == nil || cfg.Every <= 0 || cfg.ReconcileEvery <= 0 || cfg.CommandTimeout <= 0 {
|
||||
return fmt.Errorf("agentwatch: control and positive intervals/timeouts are required")
|
||||
}
|
||||
if cfg.ActiveDir == "" {
|
||||
cfg.ActiveDir = "/etc/bird"
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = log.Printf
|
||||
}
|
||||
w := &watcher{ctl: ctl, cfg: cfg}
|
||||
if err := w.step(ctx, time.Now()); err != nil && ctx.Err() == nil {
|
||||
cfg.Logf("evobgp-agent watch: %v", err)
|
||||
}
|
||||
ticker := time.NewTicker(cfg.Every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case now := <-ticker.C:
|
||||
if err := w.step(ctx, now); err != nil && ctx.Err() == nil {
|
||||
cfg.Logf("evobgp-agent watch: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *watcher) step(ctx context.Context, now time.Time) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
digest, scanErr := hashConfigTree(ctx, w.cfg.ActiveDir)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, errMainNotReady) {
|
||||
return fmt.Errorf("configuration not ready: %w", scanErr)
|
||||
}
|
||||
// Unsupported/unhashable trees retain the old every-tick configure cadence.
|
||||
w.cfg.Logf("evobgp-agent watch: cannot hash complete config tree (%v); using every-tick parse/configure", scanErr)
|
||||
} else if w.haveHash && digest == w.hash && now.Sub(w.lastConfigured) < w.cfg.ReconcileEvery {
|
||||
return nil
|
||||
}
|
||||
main := filepath.Join(w.cfg.ActiveDir, "bird.conf")
|
||||
if err := w.command(ctx, func(cctx context.Context) error { return w.ctl.ParseCheck(cctx, main) }); err != nil {
|
||||
return fmt.Errorf("parse check: %w", err)
|
||||
}
|
||||
if scanErr == nil {
|
||||
stable, err := hashConfigTree(ctx, w.cfg.ActiveDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration changed during parse check: %w", err)
|
||||
}
|
||||
if stable != digest {
|
||||
return fmt.Errorf("configuration changed during parse check; retrying")
|
||||
}
|
||||
}
|
||||
if err := w.command(ctx, w.ctl.Configure); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
w.haveHash = scanErr == nil
|
||||
if w.haveHash {
|
||||
w.hash = digest
|
||||
}
|
||||
w.lastConfigured = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) command(parent context.Context, run func(context.Context) error) error {
|
||||
ctx, cancel := context.WithTimeout(parent, w.cfg.CommandTimeout)
|
||||
defer cancel()
|
||||
return run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user