mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-17 08:50:57 -04:00
Distributed mode has not dialled a message broker since the control plane moved onto the workers' own outward tunnels and every fan-out family moved onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and the code that existed only to feed it. Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys, nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen indirect requires that only the NATS testcontainer pulled in. go.sum carries no nats line either, so the removal is not the partial kind where the require goes and the checksum stays. Deleted with them: pkg/natsauth in full, the broker client's remaining options and TLS files, the per-node JWT minting on both the register and the approve path, and the natsauth.Config parameter threaded through the node routes. The credential manager is renamed and stripped rather than deleted, because it still holds the tunnel token that every re-registration rotates. The bus flags stay accepted and ignored, and are now hidden, on every command that had them, so an existing unit file, compose file or Helm values file still starts on the day of the upgrade. What is not kept is the validation that REQUIRED one: a distributed frontend started with no bus URL is no longer fatal. The TLS paths lose type:"existingfile" deliberately, so a certificate deleted along with the broker cannot fail a startup. One operator-visible behaviour change: --nats-require-auth no longer makes an agent worker wait through admin approval. Ask for that wait with --distributed-require-auth, which already implied it. It is documented in the migration section and pinned from both sides. A deployment now needs PostgreSQL and the frontends' own HTTP listener, and nothing else. coverage-baseline.txt moves from 54.2 to 62.0. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
366 lines
12 KiB
Go
366 lines
12 KiB
Go
// Package workerregistry provides a shared HTTP client for worker node
|
|
// registration, heartbeating, draining, and deregistration against a
|
|
// LocalAI frontend. Both the backend worker (WorkerCMD) and the agent
|
|
// worker (AgentWorkerCMD) use this instead of duplicating the logic.
|
|
package workerregistry
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/xlog"
|
|
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
)
|
|
|
|
// RegistrationClient talks to the frontend's /api/node/* endpoints.
|
|
type RegistrationClient struct {
|
|
FrontendURL string
|
|
RegistrationToken string
|
|
HTTPTimeout time.Duration // used for registration calls; defaults to 10s
|
|
client *http.Client
|
|
clientOnce sync.Once
|
|
}
|
|
|
|
// httpTimeout returns the configured timeout or a sensible default.
|
|
func (c *RegistrationClient) httpTimeout() time.Duration {
|
|
if c.HTTPTimeout > 0 {
|
|
return c.HTTPTimeout
|
|
}
|
|
return 10 * time.Second
|
|
}
|
|
|
|
// httpClient returns the shared HTTP client, initializing it on first use.
|
|
func (c *RegistrationClient) httpClient() *http.Client {
|
|
c.clientOnce.Do(func() {
|
|
c.client = httpclient.NewWithTimeout(c.httpTimeout())
|
|
})
|
|
return c.client
|
|
}
|
|
|
|
// baseURL returns FrontendURL with any trailing slash stripped.
|
|
func (c *RegistrationClient) baseURL() string {
|
|
return strings.TrimRight(c.FrontendURL, "/")
|
|
}
|
|
|
|
// setAuth adds an Authorization header when a token is configured.
|
|
func (c *RegistrationClient) setAuth(req *http.Request) {
|
|
if c.RegistrationToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.RegistrationToken)
|
|
}
|
|
}
|
|
|
|
// RegisterResponse is the JSON body returned by /api/node/register.
|
|
type RegisterResponse struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status,omitempty"` // "pending" until an admin approves the node
|
|
APIToken string `json:"api_token,omitempty"`
|
|
// TunnelToken is this node's own credential for GET /api/cluster/connect.
|
|
// The frontend mints a fresh one on every registration and keeps only its
|
|
// hash, so this is the ONLY time the plaintext exists anywhere but in this
|
|
// worker's memory: a worker that discards it cannot get it back without
|
|
// registering again.
|
|
TunnelToken string `json:"tunnel_token,omitempty"`
|
|
// There are no nats_jwt / nats_user_seed fields. A frontend that predates
|
|
// this release still sends them and this decodes fine: encoding/json
|
|
// ignores a key with no field, so an old frontend talking to a new worker
|
|
// is a no-op rather than a decode failure.
|
|
}
|
|
|
|
// RegisterFull sends a single registration request and returns the full
|
|
// response (node ID, approval status, and optional API and tunnel tokens).
|
|
// Re-registration is idempotent: the frontend preserves the node row and mints
|
|
// a fresh TUNNEL token each call, so this doubles as the rotation call. It is
|
|
// the only credential a registration mints; the per-node broker JWT it used to
|
|
// carry went with the bus.
|
|
func (c *RegistrationClient) RegisterFull(ctx context.Context, body map[string]any) (*RegisterResponse, error) {
|
|
jsonBody, _ := json.Marshal(body)
|
|
url := c.baseURL() + "/api/node/register"
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setAuth(req)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("posting to %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, registrationStatusError(resp)
|
|
}
|
|
|
|
var result RegisterResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ErrRegistrationRejected marks a registration the frontend REFUSED, as opposed
|
|
// to one it could not answer.
|
|
//
|
|
// Retrying a refusal cannot change it: the request is wrong, or this worker is
|
|
// not allowed to make it. The one that matters in practice is a worker of this
|
|
// release registering against a frontend that predates it, which answers
|
|
// "address is required for backend workers" with 400, because a worker no
|
|
// longer has an address to send. Without this the retry ladder spends four
|
|
// minutes on a verdict the frontend reached instantly, and the operator watches
|
|
// it before being told anything.
|
|
//
|
|
// 408 and 429 are deliberately NOT rejections. Both are the frontend asking for
|
|
// the same request again later, which is exactly what a retry does.
|
|
var ErrRegistrationRejected = errors.New("the frontend refused this registration")
|
|
|
|
// maxRegistrationErrorBody bounds how much of a refusal's body is quoted back.
|
|
// Enough for a message, not enough for an HTML error page to bury the log line
|
|
// it is meant to explain.
|
|
const maxRegistrationErrorBody = 512
|
|
|
|
// registrationStatusError turns a non-2xx response into an error that says WHY.
|
|
//
|
|
// The body is the point. The frontend explains its refusals there
|
|
// ("address is required for backend workers", "invalid registration token"),
|
|
// and discarding it left an operator with a bare status code: the one line that
|
|
// would tell them which of several possible mistakes they made was read off the
|
|
// socket and thrown away.
|
|
func registrationStatusError(resp *http.Response) error {
|
|
detail, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationErrorBody))
|
|
if err != nil {
|
|
xlog.Debug("Could not read the frontend's registration error body", "status", resp.StatusCode, "error", err)
|
|
}
|
|
msg := strings.Join(strings.Fields(string(detail)), " ")
|
|
base := fmt.Sprintf("registration failed with status %d", resp.StatusCode)
|
|
if msg != "" {
|
|
base = fmt.Sprintf("%s: %s", base, msg)
|
|
}
|
|
if isRegistrationRejection(resp.StatusCode) {
|
|
return fmt.Errorf("%s: %w", base, ErrRegistrationRejected)
|
|
}
|
|
return errors.New(base)
|
|
}
|
|
|
|
// isRegistrationRejection reports whether a status is a verdict rather than a
|
|
// condition that may pass.
|
|
func isRegistrationRejection(status int) bool {
|
|
if status == http.StatusRequestTimeout || status == http.StatusTooManyRequests {
|
|
return false
|
|
}
|
|
return status >= 400 && status < 500
|
|
}
|
|
|
|
// RegisterWithRetry retries registration with exponential backoff.
|
|
//
|
|
// It drops every field of the response it does not name, the tunnel credential
|
|
// among them. Callers that need one use RegisterFullWithRetry.
|
|
//
|
|
// The two broker-credential returns it used to carry are gone with the bus, and
|
|
// so is the Register one-shot that existed only to carry them: it had no caller
|
|
// left once nothing dialled a broker.
|
|
func (c *RegistrationClient) RegisterWithRetry(ctx context.Context, body map[string]any, maxRetries int) (nodeID, apiToken string, err error) {
|
|
res, err := c.RegisterFullWithRetry(ctx, body, maxRetries)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return res.ID, res.APIToken, nil
|
|
}
|
|
|
|
// RegisterFullWithRetry retries registration with exponential backoff and
|
|
// returns the whole response.
|
|
func (c *RegistrationClient) RegisterFullWithRetry(ctx context.Context, body map[string]any, maxRetries int) (*RegisterResponse, error) {
|
|
backoff := 2 * time.Second
|
|
maxBackoff := 30 * time.Second
|
|
|
|
var err error
|
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
|
var res *RegisterResponse
|
|
res, err = c.RegisterFull(ctx, body)
|
|
if err == nil {
|
|
return res, nil
|
|
}
|
|
if errors.Is(err, ErrRegistrationRejected) {
|
|
// A verdict, not an outage. Reported on the first attempt so the
|
|
// reason the frontend gave is the first thing in the log rather
|
|
// than the last, after the ladder.
|
|
return nil, err
|
|
}
|
|
if attempt == maxRetries {
|
|
return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries, err)
|
|
}
|
|
xlog.Warn("Registration failed, retrying", "attempt", attempt, "next_retry", backoff, "error", err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
backoff = min(backoff*2, maxBackoff)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Heartbeat sends a single heartbeat POST with the given body.
|
|
func (c *RegistrationClient) Heartbeat(ctx context.Context, nodeID string, body map[string]any) error {
|
|
jsonBody, _ := json.Marshal(body)
|
|
url := c.baseURL() + "/api/node/" + nodeID + "/heartbeat"
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
|
if err != nil {
|
|
return fmt.Errorf("creating heartbeat request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setAuth(req)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// HeartbeatLoop runs heartbeats at the given interval until ctx is cancelled.
|
|
// bodyFn is called each tick to build the heartbeat payload (e.g. VRAM stats).
|
|
func (c *RegistrationClient) HeartbeatLoop(ctx context.Context, nodeID string, interval time.Duration, bodyFn func() map[string]any) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
body := bodyFn()
|
|
if err := c.Heartbeat(ctx, nodeID, body); err != nil {
|
|
xlog.Warn("Heartbeat failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drain sets the node to draining status via POST /api/node/:id/drain.
|
|
func (c *RegistrationClient) Drain(ctx context.Context, nodeID string) error {
|
|
url := c.baseURL() + "/api/node/" + nodeID + "/drain"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("creating drain request: %w", err)
|
|
}
|
|
c.setAuth(req)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("drain failed with status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WaitForDrain polls GET /api/node/:id/models until all models report 0
|
|
// in-flight requests, or until timeout elapses.
|
|
func (c *RegistrationClient) WaitForDrain(ctx context.Context, nodeID string, timeout time.Duration) {
|
|
url := c.baseURL() + "/api/node/" + nodeID + "/models"
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
xlog.Warn("Failed to create drain poll request", "error", err)
|
|
return
|
|
}
|
|
c.setAuth(req)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
xlog.Warn("Drain poll failed, will retry", "error", err)
|
|
select {
|
|
case <-ctx.Done():
|
|
xlog.Warn("Drain wait cancelled")
|
|
return
|
|
case <-time.After(1 * time.Second):
|
|
}
|
|
continue
|
|
}
|
|
var models []struct {
|
|
InFlight int `json:"in_flight"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&models)
|
|
resp.Body.Close()
|
|
|
|
total := 0
|
|
for _, m := range models {
|
|
total += m.InFlight
|
|
}
|
|
if total == 0 {
|
|
xlog.Info("All in-flight requests drained")
|
|
return
|
|
}
|
|
xlog.Info("Waiting for in-flight requests", "count", total)
|
|
select {
|
|
case <-ctx.Done():
|
|
xlog.Warn("Drain wait cancelled")
|
|
return
|
|
case <-time.After(1 * time.Second):
|
|
}
|
|
}
|
|
xlog.Warn("Drain timeout reached, proceeding with shutdown")
|
|
}
|
|
|
|
// Deregister marks the node as offline via POST /api/node/:id/deregister.
|
|
// The node row is preserved in the database so re-registration restores
|
|
// approval status.
|
|
func (c *RegistrationClient) Deregister(ctx context.Context, nodeID string) error {
|
|
url := c.baseURL() + "/api/node/" + nodeID + "/deregister"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("creating deregister request: %w", err)
|
|
}
|
|
c.setAuth(req)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("deregistration failed with status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GracefulDeregister performs drain -> wait -> deregister in sequence.
|
|
// This is the standard shutdown sequence for backend workers.
|
|
func (c *RegistrationClient) GracefulDeregister(nodeID string) {
|
|
if c.FrontendURL == "" || nodeID == "" {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
if err := c.Drain(ctx, nodeID); err != nil {
|
|
xlog.Warn("Failed to set drain status", "error", err)
|
|
} else {
|
|
c.WaitForDrain(ctx, nodeID, 30*time.Second)
|
|
}
|
|
|
|
if err := c.Deregister(ctx, nodeID); err != nil {
|
|
xlog.Error("Failed to deregister", "error", err)
|
|
} else {
|
|
xlog.Info("Deregistered from frontend")
|
|
}
|
|
}
|