Files
orca/internal/transport/dispatch.go
T
Jon Chery d9d0beda3b test(P03): coverage uplift — engine/transport/proxmox/audit ≥50% + dispatch.go EOF fix (REQ-055)
94 new tests across 4 packages. Coverage: engine 8.3%→65.1%, transport
26.3%→84.6%, proxmox 5.1%→82.7%, audit 0%→100%. Bug fix: dispatch.go
bytesReadCloser.Read returned fmt.Errorf("EOF") instead of io.EOF —
broke HTTP request body transmission (latent since v0.2 P02).

---ci---
project: orca
phase: 3
milestone: v0.7
status: verify
requirements:
  covered: [REQ-055]
  partial: []
---/ci---
2026-08-04 00:18:58 +00:00

264 lines
8.4 KiB
Go

// Package transport — dispatch.go implements the orca.v1.Dispatch
// service: a JSON-over-HTTP interface for cross-node job submission
// and status queries. Routes:
//
// POST /orca.v1.Dispatch/Submit -> SubmitHandler
// POST /orca.v1.Dispatch/Status -> StatusHandler
//
// mTLS is the v0.2 transport (P01). ConnectRPC is NOT used because
// it's not in go.mod (RESEARCH conclusion). The service is mounted on
// the orca daemon's mTLS listener (see internal/daemon/dispatch_handler.go).
package transport
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// SubmitRequest is the body of POST /orca.v1.Dispatch/Submit.
type SubmitRequest struct {
Target string `json:"target"` // optional explicit node id; empty = bin-pack
Spec json.RawMessage `json:"spec"` // HCL/YAML job spec, opaque to the dispatch service
IdempotencyKey string `json:"-"` // set from X-Orca-Idempotency-Key header, not body
}
// SubmitResponse is the body of a Submit reply.
type SubmitResponse struct {
JobID string `json:"job_id"`
NodeID string `json:"node_id"` // node that actually accepted the job (local or peer)
}
// StatusRequest is the body of POST /orca.v1.Dispatch/Status.
type StatusRequest struct {
JobID string `json:"job_id"`
}
// StatusResponse is the body of a Status reply.
type StatusResponse struct {
JobID string `json:"job_id"`
NodeID string `json:"node_id"`
State string `json:"state"` // "pending" | "running" | "complete" | "failed" | "stopped"
}
// Dispatcher is the contract the HTTP layer uses to actually run a
// job on a node. The engine layer implements this; the HTTP layer
// translates between JSON and Dispatcher calls.
type Dispatcher interface {
LocalSubmit(ctx context.Context, spec []byte) (jobID string, err error)
LocalStatus(ctx context.Context, jobID string) (state string, err error)
}
// SubmitHandler is an http.Handler that runs Submit on a local Dispatcher.
// It honors X-Orca-Idempotency-Key for dedupe. Errors are returned
// as JSON with an "error" field and an HTTP status code.
type SubmitHandler struct {
Dispatcher Dispatcher
Dedupe *IdempotencyStore
}
// NewSubmitHandler builds a SubmitHandler.
func NewSubmitHandler(d Dispatcher, dedupe *IdempotencyStore) *SubmitHandler {
if dedupe == nil {
dedupe = NewIdempotencyStore()
}
return &SubmitHandler{Dispatcher: d, Dedupe: dedupe}
}
// ServeHTTP implements http.Handler.
func (h *SubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
defer r.Body.Close()
var req SubmitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if len(req.Spec) == 0 {
writeError(w, http.StatusBadRequest, "spec is required")
return
}
req.IdempotencyKey = r.Header.Get(IdempotencyHeader)
// Idempotency check.
if req.IdempotencyKey != "" {
if jobID, ok := h.Dedupe.Get(req.IdempotencyKey); ok {
// Replay the previous response.
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: ""})
return
}
}
jobID, err := h.Dispatcher.LocalSubmit(r.Context(), req.Spec)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if req.IdempotencyKey != "" {
h.Dedupe.Put(req.IdempotencyKey, jobID)
}
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: "self"})
}
// StatusHandler is an http.Handler that runs Status on a local Dispatcher.
type StatusHandler struct {
Dispatcher Dispatcher
}
// NewStatusHandler builds a StatusHandler.
func NewStatusHandler(d Dispatcher) *StatusHandler {
return &StatusHandler{Dispatcher: d}
}
// ServeHTTP implements http.Handler.
func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
defer r.Body.Close()
var req StatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if req.JobID == "" {
writeError(w, http.StatusBadRequest, "job_id is required")
return
}
state, err := h.Dispatcher.LocalStatus(r.Context(), req.JobID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, StatusResponse{JobID: req.JobID, NodeID: "self", State: state})
}
// DispatchClient is the client-side wrapper that calls Submit/Status
// on a remote peer. It uses mTLS (REQ-011) and the retry helper
// (REQ-037).
type DispatchClient struct {
HTTP *MTLSClient
PeerAddr string // http://host:port or https://host:port
}
// NewDispatchClient builds a DispatchClient for a peer.
func NewDispatchClient(caPath, serverName, peerAddr string) (*DispatchClient, error) {
c, err := NewMTLSClient(caPath, serverName, "", "")
if err != nil {
return nil, fmt.Errorf("NewDispatchClient: %w", err)
}
return &DispatchClient{HTTP: c, PeerAddr: peerAddr}, nil
}
// Submit calls POST /orca.v1.Dispatch/Submit on the peer with the
// given spec and idempotency key. Retries per the default policy.
func (c *DispatchClient) Submit(ctx context.Context, spec []byte, idempotencyKey string) (*SubmitResponse, error) {
if idempotencyKey != "" {
ctx = WithIdempotencyKey(ctx, idempotencyKey)
}
body, _ := json.Marshal(SubmitRequest{Spec: spec})
policy := DefaultRetryPolicy()
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Submit", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
if k := IdempotencyKeyFromContext(ctx); k != "" {
req.Header.Set(IdempotencyHeader, k)
}
r, err := c.HTTP.Do(req)
if err == nil {
defer r.Body.Close()
if r.StatusCode == http.StatusOK {
var resp SubmitResponse
if derr := json.NewDecoder(r.Body).Decode(&resp); derr == nil {
return &resp, nil
} else {
return nil, fmt.Errorf("DispatchClient.Submit: decode: %w", derr)
}
}
err = fmt.Errorf("status %d", r.StatusCode)
err = fmt.Errorf("%w: %v", ErrTransient, err)
} else {
err = fmt.Errorf("%w: %v", ErrTransient, err)
}
// No key, not idempotent: bail on first transient error.
if IdempotencyKeyFromContext(ctx) == "" {
return nil, err
}
if attempt == policy.MaxAttempts {
return nil, err
}
// Wait with backoff, respecting ctx.
wait := backoff(policy.Initial, policy.Max, attempt)
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return nil, ctx.Err()
case <-t.C:
}
}
return nil, fmt.Errorf("DispatchClient.Submit: exhausted attempts")
}
// Status calls POST /orca.v1.Dispatch/Status on the peer. Status is
// idempotent at the verb level, so retries are always safe.
func (c *DispatchClient) Status(ctx context.Context, jobID string) (*StatusResponse, error) {
body, _ := json.Marshal(StatusRequest{JobID: jobID})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Status", bytesReader(body))
req.Header.Set("Content-Type", "application/json")
r, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("DispatchClient.Status: %w", err)
}
defer r.Body.Close()
if r.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DispatchClient.Status: status %d", r.StatusCode)
}
var resp StatusResponse
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("DispatchClient.Status: decode: %w", err)
}
return &resp, nil
}
// writeJSON encodes v as JSON and writes it with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// bytesReader is a small helper to keep this file self-contained.
type bytesReadCloser struct {
b []byte
pos int
}
func bytesReader(b []byte) *bytesReadCloser { return &bytesReadCloser{b: b} }
func (r *bytesReadCloser) Read(p []byte) (int, error) {
if r.pos >= len(r.b) {
return 0, io.EOF
}
n := copy(p, r.b[r.pos:])
r.pos += n
return n, nil
}
func (r *bytesReadCloser) Close() error { return nil }