Files
EvoBGP/internal/nodecli/sync.go
T
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

119 lines
3.6 KiB
Go

package nodecli
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/bundle"
)
// SyncConfig drives pull → verify → apply on a replica node.
type SyncConfig struct {
BundleLimits bundle.Limits // zero selects bounded large-config defaults
BaseURL string
Token string
SpeakerID string
RevisionID string // empty = latest published on CP
PubKeyB64 string
PubKeyHex string
ExtractDir string
BundlePath string // temp file; default os.TempDir()/evobgp-bundle.tar.gz
BirdBin string
BirdcBin string
Socket string
HTTPClient interface {
Do(req interface{}) (interface{}, error)
}
Timeout time.Duration
}
// SyncResult summarizes a successful sync.
type SyncResult struct {
RevisionID string `json:"revision_id"`
MainConfig string `json:"main_config,omitempty"`
}
// SyncBundle pulls (if needed), verifies Ed25519 signature, extracts, parse-checks, and birdc configure.
func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.SpeakerID) == "" {
return SyncResult{}, fmt.Errorf("nodecli: sync: base-url, token, speaker-id required")
}
if strings.TrimSpace(cfg.ExtractDir) == "" {
return SyncResult{}, fmt.Errorf("nodecli: sync: extract-dir required")
}
pub, err := loadPubKey(cfg.PubKeyB64, cfg.PubKeyHex)
if err != nil {
return SyncResult{}, fmt.Errorf("nodecli: sync: %w", err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
rev := strings.TrimSpace(cfg.RevisionID)
if rev == "" {
var err error
rev, err = fetchLatestRevisionContext(ctx, cfg.BaseURL, cfg.Token, cfg.SpeakerID)
if err != nil {
return SyncResult{}, err
}
}
raw, err := fetchBundleContext(ctx, cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev, cfg.BundleLimits)
if err != nil {
return SyncResult{}, err
}
bundlePath := strings.TrimSpace(cfg.BundlePath)
if bundlePath == "" {
bundlePath = filepath.Join(os.TempDir(), "evobgp-bundle.tar.gz")
}
if err := os.WriteFile(bundlePath, raw, 0o644); err != nil {
return SyncResult{}, err
}
v, err := bundle.VerifyGzippedTarWithLimits(raw, pub, cfg.BundleLimits)
if err != nil {
return SyncResult{}, err
}
root := filepath.Clean(cfg.ExtractDir)
if err := os.MkdirAll(root, 0o755); err != nil {
return SyncResult{}, err
}
if err := bundle.WriteExtractedFilesWithLimits(root, v, cfg.BundleLimits); err != nil {
return SyncResult{}, err
}
mainRel := v.FindMainBirdConf()
if mainRel == "" {
return SyncResult{}, fmt.Errorf("nodecli: sync: bundle has no bird.conf in manifest")
}
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
opCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
if err := ctl.ParseCheck(opCtx, mainPath); err != nil {
return SyncResult{}, err
}
if err := ctl.Configure(opCtx); err != nil {
return SyncResult{}, err
}
return SyncResult{RevisionID: rev, MainConfig: mainPath}, nil
}
// SyncResultJSON encodes SyncResult for HTTP responses.
func SyncResultJSON(r SyncResult) ([]byte, error) {
return json.Marshal(map[string]any{
"ok": true,
"applied_revision_id": r.RevisionID,
"main_config": r.MainConfig,
})
}
// LoadPublicKey exports loadPubKey for other packages.
func LoadPublicKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
return loadPubKey(pubB64, pubHex)
}