Files
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

110 lines
3.1 KiB
Go

// 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)
}