Files
orca/internal/engine/executor.go
T
ciagent 5dba3cef80 feat(P09): dispatcher, transport.dispatch, CLI surface, daemon mount
Wave B of P02. Wires the data + engine + transport layers into the
daemon HTTP surface and the CLI.

- internal/engine/executor.go — adds Submit(specBytes) and
  Status(jobID) entry points to satisfy engine.LocalExecutor
  (used by the dispatcher). Submit parses a minimal JSON wire
  spec with name/command/args/env fields; Status reads from
  store.JobRepo and returns the stringified model.JobStatus.
- internal/engine/dispatcher.go — Dispatcher struct with
  LocalExecutor + capacity repo + peer registry + idempotency
  dedupe store. Submit(target, spec, idempotencyKey) does the
  local-fit-check then bin-packing pick; if no local capacity
  and target is empty, falls through to a peer. dispatchTo /
  dispatchToPeer open mTLS clients (no cert presented by the
  client in P02; the server uses RequireAndVerifyClientCert
  but P02 ships with the cert-pool wiring without enforcing
  client certs on the dispatch endpoint — P03 hardening).
  LocalSubmit/LocalStatus satisfy transport.Dispatcher.
- internal/transport/dispatch.go — SubmitHandler and
  StatusHandler (http.Handler). SubmitHandler honors
  X-Orca-Idempotency-Key for dedupe replay. Submit/Status
  Request/Response wire structs. DispatchClient wraps
  mTLS HTTP client with the retry loop. The retry Submit
  is implemented as a direct loop (not via Do[T]) because
  the response-decode path doesn't fit the generic shape
  cleanly.
- internal/daemon/dispatch_handler.go — DispatchHandlers
  groups Submit+Status; Mount(mux) attaches both routes.
- internal/daemon/server.go — Server gets a dispatch field;
  RegisterDispatch(h) attaches the handlers; mux() mounts
  them at /orca.v1.Dispatch/{Submit,Status}.
- internal/daemon/dispatch_test.go — round-trip, idempotency
  dedupe, and validation (empty spec=400, GET=405) coverage.
- internal/cli/daemon.go — wires the dispatch service into
  the daemon: executor + peer registry + dispatcher +
  RegisterDispatch. Adds /orca.v1.Dispatch/* to the startup
  banner.
- internal/cli/job.go — adds --target and --idempotency-key
  to 'orca job run'; routes through the dispatcher when set.
- internal/cli/node_capacity.go — 'orca node capacity
  {show,set,list}' for REQ-028. --set takes --cpu, --memory,
  --disk, --node. Positivity check on all three numerics.

All tests pass with -race; gofmt -l . clean; go vet ./...
clean. P02 verification commit follows.

---ci---
project: orca
phase: 9
milestone: v0.2
status: execute
---/ci---
2026-06-03 22:45:54 +00:00

212 lines
5.1 KiB
Go

package engine
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os/exec"
"sync"
"time"
"github.com/google/uuid"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/store"
)
type Executor struct {
jobs *store.JobRepo
tasks *store.TaskRepo
log *slog.Logger
mu sync.Mutex
}
func NewExecutor(jobs *store.JobRepo, tasks *store.TaskRepo, log *slog.Logger) *Executor {
if log == nil {
log = slog.Default()
}
return &Executor{jobs: jobs, tasks: tasks, log: log}
}
// Submit is the dispatch-friendly entry point (v0.2 P02). It parses
// the spec bytes as a minimal TaskSpec and runs a single task under
// a fresh job. Returns the job ID. This is intentionally simpler
// than the v0.1 Run() entry point — the cross-node dispatch wire
// format is a flat task (one process), not a multi-task job.
//
// The spec format is a JSON object with at least:
//
// { "name": "...", "command": "...", "args": [...], "env": [...] }
//
// All fields except command are optional.
func (e *Executor) Submit(ctx context.Context, specBytes []byte) (string, error) {
type wireSpec struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env []string `json:"env"`
}
var ws wireSpec
if err := json.Unmarshal(specBytes, &ws); err != nil {
return "", fmt.Errorf("Executor.Submit: parse: %w", err)
}
if ws.Command == "" {
return "", errors.New("Executor.Submit: spec.command is required")
}
if ws.Name == "" {
ws.Name = "dispatched"
}
job := &model.Job{
ID: uuid.NewString(),
Spec: string(specBytes),
Status: model.JobStatusPending,
}
ts := TaskSpec{
Name: ws.Name,
Command: ws.Command,
Args: ws.Args,
Env: ws.Env,
}
if err := e.Run(ctx, job, []TaskSpec{ts}); err != nil {
return job.ID, err
}
return job.ID, nil
}
// Status returns the current state of a job for the Status dispatch
// endpoint. The returned string is one of: "pending", "running",
// "complete", "failed", "stopped". Maps to model.JobStatus* values.
func (e *Executor) Status(ctx context.Context, jobID string) (string, error) {
if e.jobs == nil {
return "", errors.New("Executor.Status: nil job repo")
}
j, err := e.jobs.Get(ctx, jobID)
if err != nil {
return "", err
}
return string(j.Status), nil
}
type TaskSpec struct {
Name string
Command string
Args []string
Env []string
}
func (e *Executor) Run(ctx context.Context, job *model.Job, specs []TaskSpec) error {
e.mu.Lock()
defer e.mu.Unlock()
// Insert the job first so tasks can reference it via foreign key.
if err := e.jobs.Insert(ctx, job); err != nil {
return err
}
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusRunning, 0); err != nil {
return err
}
var (
wg sync.WaitGroup
failedCount int
exitCode int
mu sync.Mutex
)
for _, ts := range specs {
wg.Add(1)
go func(ts TaskSpec) {
defer wg.Done()
if err := e.runOne(ctx, job, ts); err != nil {
mu.Lock()
failedCount++
e.log.Error("task failed",
slog.String("job_id", job.ID),
slog.String("task", ts.Name),
slog.String("error", err.Error()))
mu.Unlock()
}
}(ts)
}
wg.Wait()
if failedCount > 0 {
exitCode = 1
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusFailed, exitCode); err != nil {
return err
}
return fmt.Errorf("%d/%d tasks failed", failedCount, len(specs))
}
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusComplete, 0); err != nil {
return err
}
return nil
}
func (e *Executor) runOne(ctx context.Context, job *model.Job, ts TaskSpec) error {
task := &model.Task{
ID: uuid.NewString(),
JobID: job.ID,
Command: ts.Command,
Args: ts.Args,
Env: ts.Env,
Status: model.TaskStatusPending,
}
if err := e.tasks.Insert(ctx, task); err != nil {
return err
}
cmd := exec.CommandContext(ctx, ts.Command, ts.Args...)
cmd.Env = append(cmd.Environ(), ts.Env...)
// WaitDelay (Go 1.25+) bounds the time spent waiting on a child process
// that fails to exit after the context is canceled.
cmd.WaitDelay = 5 * time.Second
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
_ = e.tasks.UpdateKilled(ctx, task.ID)
return fmt.Errorf("start: %w", err)
}
if err := e.tasks.UpdateRunning(ctx, task.ID, cmd.Process.Pid); err != nil {
e.log.Warn("update running failed", slog.String("error", err.Error()))
}
e.log.Info("task started",
slog.String("job_id", job.ID),
slog.String("task", ts.Name),
slog.Int("pid", cmd.Process.Pid))
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
exitCode := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
} else {
exitCode = 1
}
}
_ = e.tasks.UpdateDone(ctx, task.ID, exitCode, stdout.String(), stderr.String())
if err != nil {
return err
}
return nil
case <-ctx.Done():
// WaitDelay (set above) gives the process a grace period to exit
// cleanly before being killed.
_ = e.tasks.UpdateKilled(ctx, task.ID)
return ctx.Err()
}
}