// Package birddeploy applies revision preview fragments to a shared BIRD volume with parse-check and LKG (plan ยง13). package birddeploy import ( "context" "fmt" "io/fs" "os" "path/filepath" "strings" "evobgp/internal/birdfmt" "evobgp/internal/store" ) // Config drives two-phase deploy from a revision in the store. type Config struct { ActiveDir string // e.g. /etc/bird (live config tree) StagingDir string // e.g. /tmp/evobgp-staging (same host/volume as ActiveDir) BirdBin string BirdcBin string Socket string // birdc -s } // ApplyRevision writes preview fragments to staging, runs bird -p, swaps into active, runs birdc configure; restores LKG on failure. func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revision, cfg Config) error { if rev == nil || len(rev.PreviewFragments) == 0 { return fmt.Errorf("birddeploy: no preview fragments") } active := strings.TrimSpace(cfg.ActiveDir) staging := strings.TrimSpace(cfg.StagingDir) if active == "" || staging == "" { return fmt.Errorf("birddeploy: ActiveDir and StagingDir required") } if err := os.MkdirAll(staging, 0o755); err != nil { return err } if err := os.MkdirAll(filepath.Join(active, "bird.d"), 0o755); err != nil { return err } lkg := filepath.Join(active, ".evobgp_lkg") // Phase 1: write staging tree for rel, content := range rev.PreviewFragments { rel = strings.TrimPrefix(rel, "/") if !isDeployableBirdFragment(rel) { continue } dst := filepath.Join(staging, rel) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if err := os.WriteFile(dst, []byte(content), 0o644); err != nil { return err } } mainConf := filepath.Join(staging, "bird.conf") if _, err := os.Stat(mainConf); err != nil { return fmt.Errorf("birddeploy: staging missing bird.conf: %w", err) } bc := ctl if bc == nil { bc = &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket} } if err := bc.ParseCheck(ctx, mainConf); err != nil { return fmt.Errorf("birddeploy: bird -p failed: %w", err) } // Snapshot LKG (best-effort copy of current active bird.conf + bird.d) _ = os.RemoveAll(lkg) _ = snapshotBirdTree(active, lkg) // Phase 2: atomic swap into active for rel, content := range rev.PreviewFragments { rel = strings.TrimPrefix(rel, "/") if !isDeployableBirdFragment(rel) { continue } dst := filepath.Join(active, rel) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { _ = restoreLKG(lkg, active) return err } tmp := dst + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { _ = restoreLKG(lkg, active) return err } if err := os.Rename(tmp, dst); err != nil { _ = restoreLKG(lkg, active) return err } } if err := bc.Configure(ctx); err != nil { _ = restoreLKG(lkg, active) _ = bc.Configure(ctx) return fmt.Errorf("birddeploy: birdc configure failed, restored LKG: %w", err) } return nil } // isDeployableBirdFragment skips UI-only preview keys (e.g. flattened full config) not referenced from bird.conf. func isDeployableBirdFragment(rel string) bool { return rel != "" && !strings.HasPrefix(rel, "_") } func snapshotBirdTree(activeRoot, dstRoot string) error { if err := os.MkdirAll(dstRoot, 0o755); err != nil { return err } mc := filepath.Join(activeRoot, "bird.conf") if b, err := os.ReadFile(mc); err == nil { _ = os.WriteFile(filepath.Join(dstRoot, "bird.conf"), b, 0o644) } bd := filepath.Join(activeRoot, "bird.d") if st, err := os.Stat(bd); err == nil && st.IsDir() { _ = filepath.WalkDir(bd, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } suffix, _ := filepath.Rel(activeRoot, path) out := filepath.Join(dstRoot, suffix) _ = os.MkdirAll(filepath.Dir(out), 0o755) b, err := os.ReadFile(path) if err != nil { return nil } return os.WriteFile(out, b, 0o644) }) } return nil } func restoreLKG(lkg, active string) error { if _, err := os.Stat(filepath.Join(lkg, "bird.conf")); err != nil { return err } _ = filepath.WalkDir(lkg, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } suffix, _ := filepath.Rel(lkg, path) out := filepath.Join(active, suffix) _ = os.MkdirAll(filepath.Dir(out), 0o755) b, err := os.ReadFile(path) if err != nil { return nil } return os.WriteFile(out, b, 0o644) }) return nil }