Files
orca/internal/engine/dispatcher.go
T
Jon Chery 64cbbd543e feat(C): remote deployment correctness — PVE runtime, DB record, Traefik (REQ-166)
- deployRemote branches on runtime: pve-ct/pve-vm on proxmox nodes
  invoke runtime.Registry.Prepare+Start (creates LXC/VM via SSH);
  process runtime on linux nodes uses systemd emitter; process on
  proxmox is rejected with clear error
- deployRemote emits Traefik dynamic config when spec has ports
  (TraefikEmitter.Render + SSH-push to /etc/traefik/dynamic/)
- model.Job: added Node field so job list --json reports deployed node
- job run remote case: inserts model.Job + alloc_history after
  deployRemote succeeds (job list and job stop now work for remote)
- --target name lookup: legacy dispatcher tries NodeID match
- job list UX: short 8-char IDs, NODE column, conditional EXIT (- for
  non-terminal statuses)
- emitter/traefik.go: directory provider (was single file), pve-ct/pve-vm
  registered in RegisterTraefik
- runtime/pve.go: shellQuote for image, idempotent create check

---ci---
project: orca
milestone: v0.12.18
phase: C
status: complete
requirements:
  covered: [166]
---/ci---
2026-08-10 16:21:50 +00:00

217 lines
6.9 KiB
Go

// Package engine — dispatcher.go implements the cross-node job
// dispatch logic (v0.2 P02). The dispatcher is the bridge between
// the local "should I run this?" decision (scheduler.PickNode) and
// the remote "please run this" call (transport.DispatchClient).
//
// Flow:
//
// 1. Receive a job spec (HCL bytes from the CLI).
// 2. Parse the spec into a JobSpec (cpu/mem/disk).
// 3. Check local capacity. If it fits, run locally via the local
// executor. If not, pick a peer and dispatch.
// 4. Return the job ID and the node that actually accepted it.
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"git.cloudinit.dev/coreci/orca/internal/store"
"git.cloudinit.dev/coreci/orca/internal/transport"
)
// Dispatcher is the public surface; constructed via NewDispatcher.
//
// Deprecated: v0.9 re-architecture replaces peer dispatch with a CLI-side
// scheduler + SSH-push (no orca binary on servers per R-001). The
// Dispatcher is retained for the dual-write window and scheduled for
// deletion in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006.
type Dispatcher struct {
log *slog.Logger
capacity *store.CapacityRepo
peers *PeerRegistry
executor LocalExecutor
dedupe *transport.IdempotencyStore
mu sync.Mutex
}
// LocalExecutor is the contract the dispatcher uses to run jobs on
// the local node. The engine.Executor satisfies this.
type LocalExecutor interface {
Submit(ctx context.Context, specBytes []byte) (jobID string, err error)
Status(ctx context.Context, jobID string) (state string, err error)
}
// NewDispatcher builds a Dispatcher.
func NewDispatcher(log *slog.Logger, capacity *store.CapacityRepo, peers *PeerRegistry, exec LocalExecutor) *Dispatcher {
if log == nil {
log = slog.Default()
}
return &Dispatcher{
log: log,
capacity: capacity,
peers: peers,
executor: exec,
dedupe: transport.NewIdempotencyStore(),
}
}
// Dedupe exposes the in-memory dedupe store for testing.
func (d *Dispatcher) Dedupe() *transport.IdempotencyStore { return d.dedupe }
// Submit runs the spec locally if it fits, otherwise dispatches to a
// peer. Returns the (jobID, chosenNodeID) pair. If `target` is
// non-empty, it overrides bin-packing.
func (d *Dispatcher) Submit(ctx context.Context, target string, specBytes []byte, idempotencyKey string) (jobID, nodeID string, err error) {
if len(specBytes) == 0 {
return "", "", errors.New("Dispatcher.Submit: empty spec")
}
if idempotencyKey != "" {
if jid, ok := d.dedupe.Get(idempotencyKey); ok {
return jid, "self", nil
}
}
parsed, err := parseInlineSpec(specBytes)
if err != nil {
return "", "", fmt.Errorf("Dispatcher.Submit: parse spec: %w", err)
}
// 1. Explicit target: dispatch there.
if target != "" {
return d.dispatchTo(ctx, target, specBytes, idempotencyKey)
}
// 2. Check local capacity.
if d.capacity != nil {
local, err := d.capacity.Get(ctx, "self")
if err == nil && parsed.Fits(local) {
jid, lerr := d.executor.Submit(ctx, specBytes)
if lerr != nil {
return "", "", fmt.Errorf("Dispatcher.Submit: local: %w", lerr)
}
if idempotencyKey != "" {
d.dedupe.Put(idempotencyKey, jid)
}
d.log.Info("dispatch.local",
slog.String("event", "dispatch.local"),
slog.String("job_id", jid),
slog.String("node_id", "self"),
)
return jid, "self", nil
}
}
// 3. Pick a peer.
if d.peers == nil {
return "", "", errors.New("Dispatcher.Submit: no local capacity and no peer registry")
}
peers, err := d.peers.All(ctx)
if err != nil {
return "", "", fmt.Errorf("Dispatcher.Submit: list peers: %w", err)
}
if len(peers) == 0 {
return "", "", errors.New("Dispatcher.Submit: no peers registered")
}
var caps []*store.NodeCapacity
for _, p := range peers {
caps = append(caps, p.Capacity)
}
best, _, err := PickNode(parsed, caps)
if err != nil {
return "", "", fmt.Errorf("Dispatcher.Submit: %w", err)
}
var chosen *Peer
for _, p := range peers {
if p.NodeID == best.NodeID {
chosen = p
break
}
}
if chosen == nil {
return "", "", fmt.Errorf("Dispatcher.Submit: chosen node %s has no peer record", best.NodeID)
}
return d.dispatchToPeer(ctx, chosen, specBytes, idempotencyKey)
}
// dispatchTo sends a Submit to a specific node id (looked up in the peer registry).
func (d *Dispatcher) dispatchTo(ctx context.Context, targetNode string, specBytes []byte, idempotencyKey string) (string, string, error) {
if d.peers == nil {
return "", "", errors.New("dispatchTo: no peer registry")
}
peers, err := d.peers.All(ctx)
if err != nil {
return "", "", fmt.Errorf("dispatchTo: list peers: %w", err)
}
for _, p := range peers {
if p.NodeID == targetNode {
return d.dispatchToPeer(ctx, p, specBytes, idempotencyKey)
}
}
return "", "", fmt.Errorf("dispatchTo: target node %q not found in peer registry (looked up by ID and name)", targetNode)
}
// dispatchToPeer opens an mTLS client and calls Submit on the peer.
func (d *Dispatcher) dispatchToPeer(ctx context.Context, p *Peer, specBytes []byte, idempotencyKey string) (string, string, error) {
if p.CAPath == "" || p.ServerName == "" {
return "", "", fmt.Errorf("dispatchToPeer: peer %s missing CA or server name", p.NodeID)
}
client, err := transport.NewDispatchClient(p.CAPath, p.ServerName, "https://"+p.Address)
if err != nil {
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
}
resp, err := client.Submit(ctx, specBytes, idempotencyKey)
if err != nil {
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
}
if idempotencyKey != "" {
d.dedupe.Put(idempotencyKey, resp.JobID)
}
d.log.Info("dispatch.peer",
slog.String("event", "dispatch.peer"),
slog.String("job_id", resp.JobID),
slog.String("node_id", p.NodeID),
)
return resp.JobID, p.NodeID, nil
}
// LocalSubmit / LocalStatus satisfy the transport.Dispatcher
// interface (the server-side counterpart of DispatchClient).
func (d *Dispatcher) LocalSubmit(ctx context.Context, specBytes []byte) (string, error) {
if d.executor == nil {
return "", errors.New("Dispatcher.LocalSubmit: no local executor")
}
return d.executor.Submit(ctx, specBytes)
}
func (d *Dispatcher) LocalStatus(ctx context.Context, jobID string) (string, error) {
if d.executor == nil {
return "", errors.New("Dispatcher.LocalStatus: no local executor")
}
return d.executor.Status(ctx, jobID)
}
// parseInlineSpec parses a minimal JSON spec with cpu_millicores,
// memory_mib, disk_mib fields. The CLI uses this as the wire format
// for cross-node dispatch; full HCL parsing is in internal/jobspec.
func parseInlineSpec(b []byte) (JobSpec, error) {
type wire struct {
CPUMillicores int64 `json:"cpu_millicores"`
MemoryMiB int64 `json:"memory_mib"`
DiskMiB int64 `json:"disk_mib"`
}
var w wire
if err := json.Unmarshal(b, &w); err != nil {
return JobSpec{}, fmt.Errorf("parseInlineSpec: %w", err)
}
return JobSpec{
CPUMillicores: w.CPUMillicores,
MemoryMiB: w.MemoryMiB,
DiskMiB: w.DiskMiB,
}, nil
}