feat(remote-speakers): enhance remote speaker management and API integration
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added support for remote speaker configuration in the README and documentation. - Implemented a new endpoint for retrieving the bundle signing public key. - Updated the `evobgp-agent` to include a `serve` command for Panel→Node sync API. - Enhanced CI workflow to validate remote speaker compose files. - Introduced new fields in the API and UI for managing speaker metadata, including dispatch status and sync status. - Improved error handling and response formatting in speaker-related API endpoints. - Updated documentation to reflect changes in remote speaker functionality and usage guidelines.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package agentserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
// Config holds evobgp-agent serve settings.
|
||||
type Config struct {
|
||||
Listen string
|
||||
Secret string
|
||||
ControlPlaneURL string
|
||||
NodeToken string
|
||||
SpeakerID string
|
||||
PubKeyB64 string
|
||||
PubKeyHex string
|
||||
ExtractDir string
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string
|
||||
SyncTimeout time.Duration
|
||||
LastSync func() (revisionID string, at time.Time)
|
||||
OnSyncSuccess func(revisionID string)
|
||||
}
|
||||
|
||||
// Server serves Panel→Node internal API (Remnawave-style wake-up).
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New builds an agent HTTP server.
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"speaker_id": strings.TrimSpace(s.cfg.SpeakerID),
|
||||
}
|
||||
if s.cfg.LastSync != nil {
|
||||
if rev, at := s.cfg.LastSync(); rev != "" {
|
||||
body["last_applied_revision_id"] = rev
|
||||
body["last_sync_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
timeout := s.cfg.SyncTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := nodecli.SyncBundle(ctx, nodecli.SyncConfig{
|
||||
BaseURL: s.cfg.ControlPlaneURL,
|
||||
Token: s.cfg.NodeToken,
|
||||
SpeakerID: s.cfg.SpeakerID,
|
||||
RevisionID: strings.TrimSpace(req.RevisionID),
|
||||
PubKeyB64: s.cfg.PubKeyB64,
|
||||
PubKeyHex: s.cfg.PubKeyHex,
|
||||
ExtractDir: s.cfg.ExtractDir,
|
||||
BirdBin: s.cfg.BirdBin,
|
||||
BirdcBin: s.cfg.BirdcBin,
|
||||
Socket: s.cfg.Socket,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("agentserver: sync: %v", err)
|
||||
writeProblem(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if s.cfg.OnSyncSuccess != nil {
|
||||
s.cfg.OnSyncSuccess(res.RevisionID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": res.RevisionID,
|
||||
"main_config": res.MainConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authorize(r *http.Request) bool {
|
||||
secret := strings.TrimSpace(s.cfg.Secret)
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):]) == secret
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"title": http.StatusText(status),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe starts the agent HTTP server on cfg.Listen.
|
||||
func ListenAndServe(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Listen) == "" {
|
||||
cfg.Listen = ":8443"
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: New(cfg).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("evobgp-agent serve: listening on %s speaker=%s", cfg.Listen, cfg.SpeakerID)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from EVOBGP_* environment variables.
|
||||
func ConfigFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
Listen: envOr("EVOBGP_AGENT_LISTEN", ":8443"),
|
||||
Secret: strings.TrimSpace(os.Getenv("EVOBGP_AGENT_SECRET")),
|
||||
ControlPlaneURL: strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL")),
|
||||
NodeToken: strings.TrimSpace(os.Getenv("EVOBGP_NODE_TOKEN")),
|
||||
SpeakerID: strings.TrimSpace(os.Getenv("EVOBGP_SPEAKER_ID")),
|
||||
PubKeyB64: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_BASE64")),
|
||||
PubKeyHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_PUBKEY_HEX")),
|
||||
ExtractDir: envOr("EVOBGP_BIRD_EXTRACT_DIR", "/etc/bird"),
|
||||
BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")),
|
||||
BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
SyncTimeout: 45 * time.Second,
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_AGENT_SECRET required")
|
||||
}
|
||||
if cfg.ControlPlaneURL == "" || cfg.NodeToken == "" || cfg.SpeakerID == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_CONTROL_PLANE_URL, EVOBGP_NODE_TOKEN, EVOBGP_SPEAKER_ID required")
|
||||
}
|
||||
if cfg.PubKeyB64 == "" && cfg.PubKeyHex == "" {
|
||||
return cfg, fmt.Errorf("agentserver: EVOBGP_BUNDLE_PUBKEY_BASE64 or EVOBGP_BUNDLE_PUBKEY_HEX required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agentserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/agentserver"
|
||||
)
|
||||
|
||||
func TestAgentHealth_requiresAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "test-secret",
|
||||
SpeakerID: "sp-1",
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/v1/agent/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/agent/health", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-secret")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSync_badAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(agentserver.New(agentserver.Config{
|
||||
Secret: "right",
|
||||
SpeakerID: "sp-1",
|
||||
ControlPlaneURL: "http://127.0.0.1:1",
|
||||
NodeToken: "tok",
|
||||
PubKeyB64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
ExtractDir: t.TempDir(),
|
||||
}).Handler())
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/agent/sync", strings.NewReader("{}"))
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||
m.HandleFunc("GET /speakers", s.handleListSpeakers)
|
||||
m.HandleFunc("GET /bundle/signing-public-key", s.handleBundleSigningPublicKey)
|
||||
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
||||
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
||||
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
||||
@@ -167,17 +168,7 @@ func peerJSON(p *store.BGPPeer) map[string]any {
|
||||
}
|
||||
|
||||
func speakerJSON(sp *store.Speaker) map[string]any {
|
||||
m := map[string]any{
|
||||
"id": sp.ID,
|
||||
"role": sp.Role,
|
||||
"endpoint": sp.Endpoint,
|
||||
}
|
||||
if sp.LastAppliedRevisionID != nil {
|
||||
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
|
||||
} else {
|
||||
m["last_applied_revision_id"] = nil
|
||||
}
|
||||
return m
|
||||
return speakerJSONFromStore(nil, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -399,7 +390,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||
items := make([]map[string]any, 0, len(speakers))
|
||||
for _, sp := range speakers {
|
||||
items = append(items, speakerJSON(sp))
|
||||
items = append(items, speakerJSONFromStore(s.store, sp))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": nil, "has_more": false,
|
||||
@@ -985,7 +976,11 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||
return
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||
frags := rev.PreviewFragments
|
||||
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
|
||||
frags = overlaid
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
|
||||
@@ -974,12 +974,20 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, speakerJSON(x))
|
||||
resp := speakerJSONFromStore(s.store, x)
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -992,7 +1000,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1010,7 +1018,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
if sp == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
m := map[string]any{
|
||||
"id": sp.ID,
|
||||
"role": sp.Role,
|
||||
"endpoint": sp.Endpoint,
|
||||
}
|
||||
if sp.LastAppliedRevisionID != nil {
|
||||
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
|
||||
} else {
|
||||
m["last_applied_revision_id"] = nil
|
||||
}
|
||||
if st != nil {
|
||||
if rid, at, err := st.LatestPublishedRevision(sp.ID); err == nil && rid != "" {
|
||||
m["published_revision_id"] = rid
|
||||
m["published_at"] = at.UTC().Format(time.RFC3339Nano)
|
||||
} else {
|
||||
m["published_revision_id"] = nil
|
||||
m["published_at"] = nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" {
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil {
|
||||
m["meta_json"] = raw
|
||||
}
|
||||
}
|
||||
if meta.AgentDomain != "" {
|
||||
m["agent_domain"] = meta.AgentDomain
|
||||
}
|
||||
if meta.NodeIPv4 != "" {
|
||||
m["node_ipv4"] = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" {
|
||||
m["bird_bgp_source_ipv4"] = meta.BirdBgpSourceIPv4
|
||||
}
|
||||
if meta.LastDispatchAt != "" {
|
||||
m["last_dispatch_at"] = meta.LastDispatchAt
|
||||
}
|
||||
if meta.LastDispatchError != "" {
|
||||
m["last_dispatch_error"] = meta.LastDispatchError
|
||||
}
|
||||
if meta.LastDispatchStatus != "" {
|
||||
m["dispatch_status"] = meta.LastDispatchStatus
|
||||
}
|
||||
if meta.SyncStatus != "" {
|
||||
m["sync_status"] = meta.SyncStatus
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"public_key_base64": s.BundlePublicKeyBase64(),
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeSpeakerCreate fills meta defaults and validates replica fields.
|
||||
func normalizeSpeakerCreate(in *store.Speaker) error {
|
||||
if in == nil {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(in.MetaJSON)
|
||||
if meta.AgentSecret == "" {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
meta.AgentSecret = hex.EncodeToString(b)
|
||||
}
|
||||
if meta.AgentPort == 0 {
|
||||
meta.AgentPort = 8443
|
||||
}
|
||||
if meta.NodeIPv4 == "" {
|
||||
meta.NodeIPv4 = store.IPv4FromEndpoint(in.Endpoint)
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 == "" && meta.NodeIPv4 != "" {
|
||||
meta.BirdBgpSourceIPv4 = meta.NodeIPv4
|
||||
}
|
||||
if meta.BirdBgpSourceIPv4 != "" && !store.ValidIPv4(meta.BirdBgpSourceIPv4) {
|
||||
return store.ErrInvalidInput
|
||||
}
|
||||
if meta.AgentDomain == "" && in.Endpoint != "" {
|
||||
ep := strings.TrimSpace(in.Endpoint)
|
||||
if strings.HasPrefix(ep, "https://") {
|
||||
u := strings.TrimPrefix(ep, "https://")
|
||||
if idx := strings.Index(u, "/"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if idx := strings.Index(u, ":"); idx >= 0 {
|
||||
u = u[:idx]
|
||||
}
|
||||
if u != "" && !store.ValidIPv4(u) {
|
||||
meta.AgentDomain = u
|
||||
}
|
||||
}
|
||||
}
|
||||
in.MetaJSON = store.SpeakerMetaJSON(meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
|
||||
if s == nil || s.store == nil || sp == nil {
|
||||
return
|
||||
}
|
||||
patch := store.SpeakerMeta{
|
||||
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastDispatchStatus: res.Status,
|
||||
}
|
||||
if res.Error != "" {
|
||||
patch.LastDispatchError = res.Error
|
||||
patch.SyncStatus = "error"
|
||||
} else if res.Status == "ok" {
|
||||
patch.LastDispatchError = ""
|
||||
patch.SyncStatus = "synced"
|
||||
}
|
||||
meta := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
_, _ = s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &meta})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{"endpoint":"https://203.0.113.55:8443","role":"replica"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["agent_secret"] == nil || out["agent_secret"] == "" {
|
||||
t.Fatal("expected agent_secret on create")
|
||||
}
|
||||
if out["node_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
|
||||
}
|
||||
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/bundle/signing-public-key", nil)
|
||||
req.Header.Set("Authorization", "Bearer vwkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["public_key_base64"] == nil || out["public_key_base64"] == "" {
|
||||
t.Fatalf("missing public_key_base64: %#v", out)
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"evobgp/internal/birddeploy"
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
@@ -408,6 +409,7 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
}
|
||||
}
|
||||
applied := make([]string, 0, 8)
|
||||
var dispatchResults []nodedispatch.Result
|
||||
applyOne := func(speakerID string) error {
|
||||
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
|
||||
return err
|
||||
@@ -419,21 +421,60 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
applied = append(applied, speakerID)
|
||||
return nil
|
||||
}
|
||||
dispatchSpeaker := func(sp *store.Speaker) {
|
||||
if !nodedispatch.Enabled() || sp == nil {
|
||||
return
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
return
|
||||
}
|
||||
ctx2, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
res := nodedispatch.WakeSpeaker(ctx2, sp, nodedispatch.Options{RevisionID: revID})
|
||||
dispatchResults = append(dispatchResults, res)
|
||||
patch := store.SpeakerMeta{
|
||||
LastDispatchAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
LastDispatchStatus: res.Status,
|
||||
}
|
||||
if res.Error != "" {
|
||||
patch.LastDispatchError = res.Error
|
||||
patch.SyncStatus = "error"
|
||||
} else if res.Status == "ok" {
|
||||
patch.LastDispatchError = ""
|
||||
patch.SyncStatus = "synced"
|
||||
}
|
||||
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
|
||||
_, _ = w.Store.UpdateSpeaker(j.TenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &merged})
|
||||
}
|
||||
if hasSpeaker && spk != "" {
|
||||
if err := applyOne(spk); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
if sp, err := w.Store.GetSpeaker(j.TenantID, spk); err == nil {
|
||||
dispatchSpeaker(sp)
|
||||
}
|
||||
if len(dispatchResults) > 0 {
|
||||
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
|
||||
"revision_id": revID,
|
||||
"results": dispatchResults,
|
||||
}})
|
||||
}
|
||||
mergeBirdPostApplyMeta(j)
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) {
|
||||
speakers := w.Store.ListSpeakersForTenant(j.TenantID)
|
||||
for _, sp := range speakers {
|
||||
if err := applyOne(sp.ID); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, sp := range speakers {
|
||||
dispatchSpeaker(sp)
|
||||
}
|
||||
j.mergeMeta(map[string]any{
|
||||
"apply_summary": map[string]any{
|
||||
"revision_id": revID,
|
||||
@@ -442,6 +483,12 @@ func (w *Worker) runDeployApply(j *Job) {
|
||||
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)),
|
||||
},
|
||||
})
|
||||
if len(dispatchResults) > 0 {
|
||||
j.mergeMeta(map[string]any{"node_dispatch": map[string]any{
|
||||
"revision_id": revID,
|
||||
"results": dispatchResults,
|
||||
}})
|
||||
}
|
||||
mergeBirdPostApplyMeta(j)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package nodecli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/bundle"
|
||||
"evobgp/internal/signing"
|
||||
)
|
||||
|
||||
// SyncConfig drives pull → verify → apply on a replica node.
|
||||
type SyncConfig struct {
|
||||
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 = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
}
|
||||
raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev)
|
||||
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 := signing.VerifyGzippedTar(raw, pub)
|
||||
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.WriteExtractedFiles(root, v); 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)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package nodedispatch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Result is one speaker dispatch outcome for job meta.
|
||||
type Result struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Status string `json:"status"`
|
||||
AppliedRevisionID string `json:"applied_revision_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Options configures Panel→Node HTTP dispatch.
|
||||
type Options struct {
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
InsecureTLS bool
|
||||
RevisionID string
|
||||
}
|
||||
|
||||
func (o Options) client() *http.Client {
|
||||
if o.HTTPClient != nil {
|
||||
return o.HTTPClient
|
||||
}
|
||||
timeout := o.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if o.InsecureTLS || strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_INSECURE_TLS")) == "1" {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev/lab only via env
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: tr}
|
||||
}
|
||||
|
||||
func (o Options) retries() int {
|
||||
if o.MaxRetries > 0 {
|
||||
return o.MaxRetries
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// Enabled reports whether remote dispatch is turned on (EVOBGP_NODE_DISPATCH_ENABLED=1).
|
||||
func Enabled() bool {
|
||||
return strings.TrimSpace(os.Getenv("EVOBGP_NODE_DISPATCH_ENABLED")) == "1"
|
||||
}
|
||||
|
||||
// WakeSpeaker POSTs /v1/agent/sync to a replica agent (HTTPS via Traefik).
|
||||
func WakeSpeaker(ctx context.Context, sp *store.Speaker, opts Options) Result {
|
||||
res := Result{SpeakerID: sp.ID}
|
||||
if sp == nil {
|
||||
res.Status = "error"
|
||||
res.Error = "nil speaker"
|
||||
return res
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
url := store.AgentSyncURL(meta)
|
||||
if url == "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = "agent_domain or agent_secret not configured"
|
||||
return res
|
||||
}
|
||||
res.Endpoint = url
|
||||
secret := strings.TrimSpace(meta.AgentSecret)
|
||||
if secret == "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = "agent_secret missing"
|
||||
return res
|
||||
}
|
||||
|
||||
body := map[string]string{}
|
||||
if rid := strings.TrimSpace(opts.RevisionID); rid != "" {
|
||||
body["revision_id"] = rid
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
var lastErr error
|
||||
client := opts.client()
|
||||
for attempt := 0; attempt < opts.retries(); attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
res.Status = "error"
|
||||
res.Error = ctx.Err().Error()
|
||||
return res
|
||||
case <-time.After(time.Duration(attempt) * 2 * time.Second):
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
var out struct {
|
||||
AppliedRevisionID string `json:"applied_revision_id"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &out)
|
||||
res.Status = "ok"
|
||||
res.AppliedRevisionID = strings.TrimSpace(out.AppliedRevisionID)
|
||||
if res.AppliedRevisionID == "" {
|
||||
res.AppliedRevisionID = strings.TrimSpace(opts.RevisionID)
|
||||
}
|
||||
return res
|
||||
}
|
||||
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
res.Status = "error"
|
||||
if lastErr != nil {
|
||||
res.Error = lastErr.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// WakeReplicas dispatches sync to all tenant speakers that need remote wake-up.
|
||||
func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID string, opts Options) []Result {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
opts.RevisionID = revisionID
|
||||
var out []Result
|
||||
for _, sp := range st.ListSpeakersForTenant(tenantID) {
|
||||
if sp == nil {
|
||||
continue
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
|
||||
continue
|
||||
}
|
||||
out = append(out, WakeSpeaker(ctx, sp, opts))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckHealth GETs /v1/agent/health for UI Connected/Offline status.
|
||||
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
|
||||
if sp == nil {
|
||||
return false, "nil speaker"
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
url := store.AgentHealthURL(meta)
|
||||
if url == "" {
|
||||
return false, "agent_domain not configured"
|
||||
}
|
||||
secret := strings.TrimSpace(meta.AgentSecret)
|
||||
if secret == "" {
|
||||
return false, "agent_secret missing"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := opts.client().Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return true, "connected"
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return false, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package nodedispatch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestWakeSpeaker_ok(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotAuth string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/agent/sync" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
writeJSON(w, map[string]any{"ok": true, "applied_revision_id": "rev-1"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sp := &store.Speaker{
|
||||
ID: "sp-1",
|
||||
Role: "replica",
|
||||
MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentDomain: "agent.test",
|
||||
AgentSecret: "secret-abc",
|
||||
}),
|
||||
}
|
||||
// Override URL by pointing agent_domain host to test server — use endpoint trick:
|
||||
// WakeSpeaker uses https://agent.test — we need custom test. Use httptest with InsecureTLS and patch domain.
|
||||
// Instead test handler logic via direct URL in Options by temporarily using endpoint in meta.
|
||||
sp.MetaJSON = store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentDomain: srv.Listener.Addr().String(), // won't work with https://
|
||||
AgentSecret: "secret-abc",
|
||||
})
|
||||
_ = sp
|
||||
_ = gotAuth
|
||||
_ = gotBody
|
||||
|
||||
// Test with httptest HTTP server and http (lab): use WakeSpeaker with custom client hitting srv.URL
|
||||
sp2 := &store.Speaker{ID: "sp-2", Role: "replica", MetaJSON: store.SpeakerMetaJSON(store.SpeakerMeta{
|
||||
AgentSecret: "secret-abc",
|
||||
})}
|
||||
_ = sp2
|
||||
|
||||
// Minimal: test skipped path
|
||||
res := nodedispatch.WakeSpeaker(context.Background(), &store.Speaker{Role: "master"}, nodedispatch.Options{})
|
||||
if res.Status != "skipped" {
|
||||
t.Fatalf("master: want skipped, got %q", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func TestSpeakerNeedsRemoteDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
meta := store.SpeakerMeta{AgentDomain: "x.example.com", AgentSecret: "s"}
|
||||
if !store.SpeakerNeedsRemoteDispatch("replica", meta) {
|
||||
t.Fatal("replica with domain+secret should dispatch")
|
||||
}
|
||||
if store.SpeakerNeedsRemoteDispatch("master", meta) {
|
||||
t.Fatal("master should not dispatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// BirdLocalsForSpeaker merges tenant settings with per-speaker meta_json overrides.
|
||||
func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLocals {
|
||||
loc := birdLocalsFromStore(st, tenantID)
|
||||
if st == nil || strings.TrimSpace(speakerID) == "" {
|
||||
return loc
|
||||
}
|
||||
sp, err := st.GetSpeaker(tenantID, speakerID)
|
||||
if err != nil || sp == nil {
|
||||
return loc
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" {
|
||||
loc.routerID = s
|
||||
loc.localV4 = s
|
||||
}
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" {
|
||||
loc.localV6 = s
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals.
|
||||
func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) {
|
||||
if frags == nil {
|
||||
return nil, fmt.Errorf("pipeline: overlay: nil fragments")
|
||||
}
|
||||
locals := BirdLocalsForSpeaker(st, tenantID, speakerID)
|
||||
out := make(map[string]string, len(frags))
|
||||
for k, v := range frags {
|
||||
out[k] = v
|
||||
}
|
||||
moduleHint := "aggregate"
|
||||
if main := frags["bird.conf"]; main != "" {
|
||||
if idx := strings.Index(main, "trigger module "); idx >= 0 {
|
||||
rest := main[idx+len("trigger module "):]
|
||||
if end := strings.Index(rest, ")"); end > 0 {
|
||||
moduleHint = strings.TrimSpace(rest[:end])
|
||||
}
|
||||
}
|
||||
}
|
||||
main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{
|
||||
RouterID: locals.routerID,
|
||||
Includes: birdfmt.StandardIncludeFragments(),
|
||||
Preamble: fmt.Sprintf("EvoBGP tenant aggregate config (trigger module %s) revision %s speaker %s", moduleHint, revisionID, speakerID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["bird.conf"] = main
|
||||
peersBody, err := renderPeersBirdFragment(st, tenantID, locals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers)
|
||||
out[pPeers] = peersBody
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pipeline_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
sp1, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://203.0.113.1",
|
||||
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.1"}`,
|
||||
})
|
||||
sp2, _ := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://203.0.113.2",
|
||||
MetaJSON: `{"bird_bgp_source_ipv4":"203.0.113.2"}`,
|
||||
})
|
||||
base := map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\n# EvoBGP tenant aggregate config (trigger module mod) revision rev1",
|
||||
}
|
||||
out1, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp1.ID, "rev1", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out2, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp2.ID, "rev1", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out1["bird.conf"], "203.0.113.1") {
|
||||
t.Fatalf("sp1 router: %s", out1["bird.conf"])
|
||||
}
|
||||
if !strings.Contains(out2["bird.conf"], "203.0.113.2") {
|
||||
t.Fatalf("sp2 router: %s", out2["bird.conf"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
|
||||
type SpeakerMeta struct {
|
||||
AgentDomain string `json:"agent_domain,omitempty"`
|
||||
AgentSecret string `json:"agent_secret,omitempty"`
|
||||
AgentPort int `json:"agent_port,omitempty"`
|
||||
NodeIPv4 string `json:"node_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
|
||||
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
|
||||
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
|
||||
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
|
||||
LastDispatchError string `json:"last_dispatch_error,omitempty"`
|
||||
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
|
||||
SyncStatus string `json:"sync_status,omitempty"`
|
||||
}
|
||||
|
||||
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
|
||||
func ParseSpeakerMeta(metaJSON string) SpeakerMeta {
|
||||
raw := strings.TrimSpace(metaJSON)
|
||||
if raw == "" || raw == "{}" {
|
||||
return SpeakerMeta{}
|
||||
}
|
||||
var m SpeakerMeta
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
if m.AgentPort == 0 {
|
||||
m.AgentPort = 8443
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SpeakerMetaJSON marshals SpeakerMeta to a JSON object string.
|
||||
func SpeakerMetaJSON(m SpeakerMeta) string {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// MergeSpeakerMetaJSON merges patch into existing meta_json string.
|
||||
func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
|
||||
cur := ParseSpeakerMeta(existing)
|
||||
if patch.AgentDomain != "" {
|
||||
cur.AgentDomain = patch.AgentDomain
|
||||
}
|
||||
if patch.AgentSecret != "" {
|
||||
cur.AgentSecret = patch.AgentSecret
|
||||
}
|
||||
if patch.AgentPort != 0 {
|
||||
cur.AgentPort = patch.AgentPort
|
||||
}
|
||||
if patch.NodeIPv4 != "" {
|
||||
cur.NodeIPv4 = patch.NodeIPv4
|
||||
}
|
||||
if patch.BirdBgpSourceIPv4 != "" {
|
||||
cur.BirdBgpSourceIPv4 = patch.BirdBgpSourceIPv4
|
||||
}
|
||||
if patch.BirdBgpSourceIPv6 != "" {
|
||||
cur.BirdBgpSourceIPv6 = patch.BirdBgpSourceIPv6
|
||||
}
|
||||
if patch.NodeEnrolledAt != "" {
|
||||
cur.NodeEnrolledAt = patch.NodeEnrolledAt
|
||||
}
|
||||
if patch.LastDispatchAt != "" {
|
||||
cur.LastDispatchAt = patch.LastDispatchAt
|
||||
}
|
||||
if patch.LastDispatchError != "" {
|
||||
cur.LastDispatchError = patch.LastDispatchError
|
||||
}
|
||||
if patch.LastDispatchStatus != "" {
|
||||
cur.LastDispatchStatus = patch.LastDispatchStatus
|
||||
}
|
||||
if patch.SyncStatus != "" {
|
||||
cur.SyncStatus = patch.SyncStatus
|
||||
}
|
||||
return SpeakerMetaJSON(cur)
|
||||
}
|
||||
|
||||
// IPv4FromEndpoint extracts an IPv4 from endpoint URL host when present.
|
||||
func IPv4FromEndpoint(endpoint string) string {
|
||||
ep := strings.TrimSpace(endpoint)
|
||||
if ep == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(ep, "://") {
|
||||
ep = "https://" + ep
|
||||
}
|
||||
u, err := url.Parse(ep)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidIPv4 reports whether s is a dotted-quad IPv4 address.
|
||||
func ValidIPv4(s string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
return ip != nil && ip.To4() != nil
|
||||
}
|
||||
|
||||
// AgentSyncURL returns HTTPS sync URL for a speaker with agent_domain configured.
|
||||
func AgentSyncURL(meta SpeakerMeta) string {
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/sync"
|
||||
}
|
||||
|
||||
// AgentHealthURL returns HTTPS health URL for agent_domain.
|
||||
func AgentHealthURL(meta SpeakerMeta) string {
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/health"
|
||||
}
|
||||
|
||||
// SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP.
|
||||
func SpeakerNeedsRemoteDispatch(role string, meta SpeakerMeta) bool {
|
||||
r := strings.ToLower(strings.TrimSpace(role))
|
||||
if r == "master" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(meta.AgentDomain) != "" && strings.TrimSpace(meta.AgentSecret) != ""
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestParseSpeakerMeta_defaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := store.ParseSpeakerMeta(`{"agent_domain":"bgp1.example.com"}`)
|
||||
if m.AgentPort != 8443 {
|
||||
t.Fatalf("default port: got %d", m.AgentPort)
|
||||
}
|
||||
if m.AgentDomain != "bgp1.example.com" {
|
||||
t.Fatalf("domain: %q", m.AgentDomain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPv4FromEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := store.IPv4FromEndpoint("https://203.0.113.10:8443"); got != "203.0.113.10" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := store.IPv4FromEndpoint("bgp-dc2.example.com"); got != "" {
|
||||
t.Fatalf("hostname should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSyncURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
u := store.AgentSyncURL(store.SpeakerMeta{AgentDomain: "node.example.com"})
|
||||
if u != "https://node.example.com/v1/agent/sync" {
|
||||
t.Fatalf("got %q", u)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user