Harden worker federation and operator UI
This commit is contained in:
@@ -124,8 +124,8 @@ func (c Client) Ack(ctx context.Context, cursor uint64) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (c Client) Heartbeat(ctx context.Context) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", nil)
|
||||
func (c Client) Heartbeat(ctx context.Context, health WorkerHealth) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", health)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -146,6 +146,8 @@ func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
h := c.HTTP
|
||||
if h == nil {
|
||||
h = http.DefaultClient
|
||||
|
||||
@@ -2,8 +2,11 @@ package federation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -12,12 +15,26 @@ var ErrUnknownWorker = errors.New("unknown worker")
|
||||
var ErrUnauthorized = errors.New("worker authentication failed")
|
||||
|
||||
type Worker struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Token string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Health WorkerHealth `json:"health"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
// WorkerHealth is reported by the worker that owns the local herdr socket.
|
||||
// It intentionally does not reuse coordinator TCP-probe state: a remote
|
||||
// socket is meaningful only from the machine where the worker and checkout
|
||||
// live.
|
||||
type WorkerHealth struct {
|
||||
HerdrStatus string `json:"herdr_status"` // reachable, unreachable, or unknown
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
ActiveTask string `json:"active_task_id,omitempty"`
|
||||
ActivePane string `json:"active_pane_id,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ErrorAt time.Time `json:"error_at,omitempty"`
|
||||
}
|
||||
|
||||
// Capture is published by a worker that owns the pane. The coordinator never
|
||||
@@ -53,6 +70,88 @@ type Registry struct {
|
||||
cursors map[string]uint64
|
||||
captures map[string]Capture // worker/task
|
||||
commands map[string][]Command
|
||||
// StatePath preserves worker-owned pane captures and pending approval
|
||||
// commands across coordinator restarts. A worker must still re-register to
|
||||
// be online before it can read or act on recovered state.
|
||||
StatePath string
|
||||
}
|
||||
|
||||
type persistedState struct {
|
||||
Captures map[string]Capture `json:"captures"`
|
||||
Commands map[string][]Command `json:"commands"`
|
||||
Workers map[string]persistedWorker `json:"workers"`
|
||||
}
|
||||
|
||||
// persistedWorker deliberately includes the per-worker token. The state file
|
||||
// is mode 0600, and retaining this binding prevents an arbitrary process from
|
||||
// registering a recovered worker ID and executing its pending approval.
|
||||
type persistedWorker struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Load restores durable capture/command state. Call this before accepting
|
||||
// federation requests; an unreadable state file is unsafe because it could
|
||||
// otherwise make a pending approval silently disappear.
|
||||
func (r *Registry) Load() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
if r.StatePath == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(r.StatePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var state persistedState
|
||||
if err := json.Unmarshal(b, &state); err != nil {
|
||||
return fmt.Errorf("invalid federation state: %w", err)
|
||||
}
|
||||
if state.Captures != nil {
|
||||
r.captures = state.Captures
|
||||
}
|
||||
if state.Commands != nil {
|
||||
r.commands = state.Commands
|
||||
}
|
||||
for id, w := range state.Workers {
|
||||
if id == "" || w.ID != id || w.Token == "" {
|
||||
return fmt.Errorf("invalid federation worker %q", id)
|
||||
}
|
||||
r.workers[id] = Worker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistLocked atomically replaces the state file. Callers hold r.mu.
|
||||
func (r *Registry) persistLocked() error {
|
||||
if r.StatePath == "" {
|
||||
return nil
|
||||
}
|
||||
workers := make(map[string]persistedWorker, len(r.workers))
|
||||
for id, w := range r.workers {
|
||||
workers[id] = persistedWorker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
|
||||
}
|
||||
b, err := json.Marshal(persistedState{Captures: r.captures, Commands: r.commands, Workers: workers})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(r.StatePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := r.StatePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, r.StatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(r.StatePath, 0600)
|
||||
}
|
||||
|
||||
func (r *Registry) init() {
|
||||
@@ -94,6 +193,9 @@ func (r *Registry) PutCapture(worker string, c Capture) (Capture, error) {
|
||||
}
|
||||
c.At = time.Now().UTC()
|
||||
r.captures[k] = c
|
||||
if err := r.persistLocked(); err != nil {
|
||||
return Capture{}, fmt.Errorf("persist capture: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func (r *Registry) Capture(worker, task string) (Capture, bool) {
|
||||
@@ -118,6 +220,9 @@ func (r *Registry) Queue(worker string, c Command) (Command, error) {
|
||||
c.Status = "pending"
|
||||
r.commands[worker] = append(r.commands[worker], c)
|
||||
r.pruneCommands(worker)
|
||||
if err := r.persistLocked(); err != nil {
|
||||
return Command{}, fmt.Errorf("persist command: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -129,7 +234,7 @@ const CommandRetention = 30 * time.Minute
|
||||
// pruneCommands drops resolved commands past CommandRetention. B21: this list
|
||||
// was append-only, so resolved commands accumulated for the process lifetime
|
||||
// and every worker poll rescanned the entire history. Callers hold r.mu.
|
||||
func (r *Registry) pruneCommands(worker string) {
|
||||
func (r *Registry) pruneCommands(worker string) bool {
|
||||
cutoff := time.Now().UTC().Add(-CommandRetention)
|
||||
in := r.commands[worker]
|
||||
out := in[:0]
|
||||
@@ -139,10 +244,12 @@ func (r *Registry) pruneCommands(worker string) {
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
changed := len(in) != 0
|
||||
delete(r.commands, worker)
|
||||
return
|
||||
return changed
|
||||
}
|
||||
r.commands[worker] = out
|
||||
return len(out) != len(in)
|
||||
}
|
||||
func (r *Registry) Commands(worker string) ([]Command, error) {
|
||||
r.mu.Lock()
|
||||
@@ -151,7 +258,11 @@ func (r *Registry) Commands(worker string) ([]Command, error) {
|
||||
if _, ok := r.workers[worker]; !ok {
|
||||
return nil, ErrUnknownWorker
|
||||
}
|
||||
r.pruneCommands(worker)
|
||||
if r.pruneCommands(worker) {
|
||||
if err := r.persistLocked(); err != nil {
|
||||
return nil, fmt.Errorf("persist pruned commands: %w", err)
|
||||
}
|
||||
}
|
||||
var out []Command
|
||||
for _, c := range r.commands[worker] {
|
||||
if c.Status == "pending" {
|
||||
@@ -182,6 +293,9 @@ func (r *Registry) CompleteCommand(worker, id, status, message string) error {
|
||||
}
|
||||
r.commands[worker][i].Status = status
|
||||
r.commands[worker][i].Error = message
|
||||
if err := r.persistLocked(); err != nil {
|
||||
return fmt.Errorf("persist command resolution: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -214,6 +328,9 @@ func (r *Registry) Register(w Worker, admitToken string) error {
|
||||
if _, ok := r.cursors[w.ID]; !ok {
|
||||
r.cursors[w.ID] = 0
|
||||
}
|
||||
if err := r.persistLocked(); err != nil {
|
||||
return fmt.Errorf("persist worker registration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Registry) Authenticate(id, token string) error {
|
||||
@@ -251,7 +368,7 @@ func (r *Registry) Ack(id string, cursor uint64) error {
|
||||
r.cursors[id] = cursor
|
||||
return nil
|
||||
}
|
||||
func (r *Registry) Heartbeat(id string) error {
|
||||
func (r *Registry) Heartbeat(id string, health ...WorkerHealth) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
@@ -261,6 +378,9 @@ func (r *Registry) Heartbeat(id string) error {
|
||||
}
|
||||
w.LastSeen = time.Now().UTC()
|
||||
w.Online = true
|
||||
if len(health) > 0 {
|
||||
w.Health = health[0]
|
||||
}
|
||||
r.workers[id] = w
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package federation
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -27,6 +29,68 @@ func TestCursorIsMonotonicAndAuthenticationIsRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingApprovalSurvivesRegistryRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "federation-state.json")
|
||||
r := &Registry{StatePath: path}
|
||||
if err := r.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capture, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: "Allow command?"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued, err := r.Queue("w", Command{TaskID: "task", Kind: "grant_approval", PaneID: "pane", CaptureRevision: capture.Revision})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("federation state permissions = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
restarted := &Registry{StatePath: path}
|
||||
if err := restarted.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.Register(Worker{ID: "w", Token: "intruder"}, ""); err != ErrUnauthorized {
|
||||
t.Fatalf("recovered worker identity was hijackable: %v", err)
|
||||
}
|
||||
// A restart does not mark the worker online; it must prove its retained
|
||||
// identity by registering again before recovered controls become available.
|
||||
if err := restarted.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotCapture, ok := restarted.Capture("w", "task")
|
||||
if !ok || gotCapture.Revision != capture.Revision || gotCapture.Text != capture.Text {
|
||||
t.Fatalf("capture after restart = %#v, present=%v", gotCapture, ok)
|
||||
}
|
||||
commands, err := restarted.Commands("w")
|
||||
if err != nil || len(commands) != 1 || commands[0].ID != queued.ID {
|
||||
t.Fatalf("commands after restart = %#v, err=%v", commands, err)
|
||||
}
|
||||
if err := restarted.CompleteCommand("w", queued.ID, "acknowledged", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
again := &Registry{StatePath: path}
|
||||
if err := again.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := again.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands, err = again.Commands("w")
|
||||
if err != nil || len(commands) != 0 {
|
||||
t.Fatalf("resolved command recovered as pending: %#v, err=%v", commands, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRequiresAdmitTokenAndOwnToken(t *testing.T) {
|
||||
r := &Registry{AdmitToken: "admit-secret"}
|
||||
if err := r.Register(Worker{ID: "workpc", Token: "secret"}, "wrong"); err != ErrUnauthorized {
|
||||
@@ -76,6 +140,29 @@ func TestOfflineHookRunsOnceOnTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatProjectsWorkerOwnedHealth(t *testing.T) {
|
||||
r := &Registry{}
|
||||
if err := r.Register(Worker{ID: "workpc-opencode", Token: "secret"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked := time.Now().UTC().Round(0)
|
||||
errAt := checked.Add(-time.Minute)
|
||||
if err := r.Heartbeat("workpc-opencode", WorkerHealth{
|
||||
HerdrStatus: "unreachable", CheckedAt: checked, ActiveTask: "task-1", ActivePane: "pane-1",
|
||||
LastError: "local herdr: connection refused", ErrorAt: errAt,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workers := r.Snapshot()
|
||||
if len(workers) != 1 {
|
||||
t.Fatalf("workers=%#v", workers)
|
||||
}
|
||||
h := workers[0].Health
|
||||
if h.HerdrStatus != "unreachable" || h.ActiveTask != "task-1" || h.ActivePane != "pane-1" || h.LastError == "" || !h.CheckedAt.Equal(checked) || !h.ErrorAt.Equal(errAt) {
|
||||
t.Fatalf("health=%#v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRevisionAndCommandQueue(t *testing.T) {
|
||||
r := &Registry{}
|
||||
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user