Files
EvoBGP/internal/nodedispatch/dispatch.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

190 lines
4.8 KiB
Go

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"`
Confirmation string `json:"confirmation,omitempty"`
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{}
if sp == nil {
res.Status = "error"
res.Error = "nil speaker"
return res
}
res.SpeakerID = sp.ID
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 {
OK *bool `json:"ok"`
AppliedRevisionID string `json:"applied_revision_id"`
Error string `json:"error"`
}
if len(bytes.TrimSpace(b)) > 0 {
if err := json.Unmarshal(b, &out); err != nil {
res.Status = "error"
res.Error = "invalid agent sync response"
return res
}
}
if (out.OK != nil && !*out.OK) || strings.TrimSpace(out.Error) != "" {
res.Status = "error"
res.Error = strings.TrimSpace(out.Error)
if res.Error == "" {
res.Error = "agent sync reported failure"
}
return res
}
res.Status = "ok"
res.AppliedRevisionID = strings.TrimSpace(out.AppliedRevisionID)
if res.AppliedRevisionID != "" {
res.Confirmation = "agent_reported"
} else {
res.Confirmation = "unconfirmed"
}
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 is deprecated; use FetchAgentHealth.
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
res := FetchAgentHealth(ctx, sp, opts)
if res.OK {
return true, "connected"
}
if res.Error != "" {
return false, res.Error
}
return false, "agent unhealthy"
}