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