feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
924f111e48
commit
f8e0520817
197 files changed
+18655
-1416
No files matched your search
@@ -61,13 +61,17 @@ func nodeHTTPClient() *http.Client {
|
||||
}
|
||||
|
||||
func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
return fetchLatestRevisionContext(context.Background(), base, token, speaker)
|
||||
}
|
||||
|
||||
func fetchLatestRevisionContext(parent context.Context, base, token, speaker string) (string, error) {
|
||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(parent, 60*time.Second)
|
||||
defer cancel()
|
||||
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
|
||||
if err != nil {
|
||||
@@ -75,13 +79,17 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
body, err := readHTTPBytes(resp, 64<<10)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.RevisionID == "" {
|
||||
@@ -91,13 +99,21 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
|
||||
}
|
||||
|
||||
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||
return fetchBundleContext(context.Background(), base, token, speaker, revision, bundle.Limits{})
|
||||
}
|
||||
|
||||
func fetchBundleContext(parent context.Context, base, token, speaker, revision string, limits bundle.Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
ctx, cancel := context.WithTimeout(parent, 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
|
||||
if err != nil {
|
||||
@@ -105,10 +121,37 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
return readHTTPBytes(resp, l.MaxCompressedBytes)
|
||||
}
|
||||
|
||||
func readHTTPBytes(resp *http.Response, limit int64) ([]byte, error) {
|
||||
if resp.ContentLength > limit {
|
||||
return nil, bundle.ErrLimit
|
||||
}
|
||||
return bundle.ReadCompressed(resp.Body, bundle.Limits{MaxCompressedBytes: limit})
|
||||
}
|
||||
|
||||
func readBundleFile(path string, limits bundle.Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() > l.MaxCompressedBytes {
|
||||
return nil, bundle.ErrLimit
|
||||
}
|
||||
return bundle.ReadCompressed(f, l)
|
||||
}
|
||||
|
||||
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
|
||||
@@ -146,7 +189,7 @@ func CmdVerifyBundle(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
raw, err := os.ReadFile(*path)
|
||||
raw, err := readBundleFile(*path, bundle.Limits{})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
@@ -182,7 +225,7 @@ func CmdApplyBundle(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
raw, err := os.ReadFile(*path)
|
||||
raw, err := readBundleFile(*path, bundle.Limits{})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package nodecli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/bundle"
|
||||
)
|
||||
|
||||
type countingBody struct {
|
||||
io.Reader
|
||||
read int
|
||||
}
|
||||
|
||||
func (b *countingBody) Read(p []byte) (int, error) {
|
||||
n, e := b.Reader.Read(p)
|
||||
b.read += n
|
||||
return n, e
|
||||
}
|
||||
func (b *countingBody) Close() error { return nil }
|
||||
|
||||
func TestHTTPBundleBoundKnownAndChunked(t *testing.T) {
|
||||
for _, length := range []int64{-1, 2048} {
|
||||
body := &countingBody{Reader: bytes.NewReader(bytes.Repeat([]byte{'x'}, 2048))}
|
||||
_, err := readHTTPBytes(&http.Response{Body: body, ContentLength: length}, 1024)
|
||||
if !errors.Is(err, bundle.ErrLimit) {
|
||||
t.Fatalf("length=%d limit=%v", length, err)
|
||||
}
|
||||
if body.read > 1025 || (length > 1024 && body.read != 0) {
|
||||
t.Fatalf("unbounded read %d for length %d", body.read, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBundleSizeBound(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bundle.tar.gz")
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, 2048), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readBundleFile(path, bundle.Limits{MaxCompressedBytes: 1024}); !errors.Is(err, bundle.ErrLimit) {
|
||||
t.Fatalf("local input cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleHTTPMetadataAndDiagnosticsBound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/revisions/latest"):
|
||||
_, _ = io.WriteString(w, strings.Repeat(" ", 65<<10))
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, strings.Repeat("x", 64<<10))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
if _, err := fetchLatestRevision(server.URL, "key", "speaker"); !errors.Is(err, bundle.ErrLimit) {
|
||||
t.Fatalf("metadata unbounded: %v", err)
|
||||
}
|
||||
if _, err := fetchBundle(server.URL, "key", "speaker", "revision"); err == nil || len(err.Error()) > 5<<10 {
|
||||
t.Fatalf("diagnostic unbounded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFetchRespectsCallerCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := fetchBundleContext(ctx, "http://127.0.0.1:1", "key", "speaker", "revision", bundle.Limits{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("download ignored cancel: %v", err)
|
||||
}
|
||||
if _, err := fetchLatestRevisionContext(ctx, "http://127.0.0.1:1", "key", "speaker"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("metadata ignored cancel: %v", err)
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -12,23 +12,23 @@ import (
|
||||
|
||||
"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 {
|
||||
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
|
||||
@@ -59,12 +59,12 @@ func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
|
||||
rev := strings.TrimSpace(cfg.RevisionID)
|
||||
if rev == "" {
|
||||
var err error
|
||||
rev, err = fetchLatestRevision(cfg.BaseURL, cfg.Token, cfg.SpeakerID)
|
||||
rev, err = fetchLatestRevisionContext(ctx, cfg.BaseURL, cfg.Token, cfg.SpeakerID)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
}
|
||||
raw, err := fetchBundle(cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev)
|
||||
raw, err := fetchBundleContext(ctx, cfg.BaseURL, cfg.Token, cfg.SpeakerID, rev, cfg.BundleLimits)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
|
||||
if err := os.WriteFile(bundlePath, raw, 0o644); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
v, err := signing.VerifyGzippedTar(raw, pub)
|
||||
v, err := bundle.VerifyGzippedTarWithLimits(raw, pub, cfg.BundleLimits)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func SyncBundle(ctx context.Context, cfg SyncConfig) (SyncResult, error) {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
if err := bundle.WriteExtractedFiles(root, v); err != nil {
|
||||
if err := bundle.WriteExtractedFilesWithLimits(root, v, cfg.BundleLimits); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
mainRel := v.FindMainBirdConf()
|
||||
|
||||
Reference in new issue
Block a user