ce2441f312
---ci--- project: orca phase: 1 milestone: v0.12 status: execute ---/ci--- shellQuote the jobspec-supplied command string (cmdStr) before interpolating into SSH exec in podman.go (Start) and wasm.go (Start). Previously cmdStr was interpolated unquoted, allowing a malicious jobspec command with shell metacharacters (; | $() backticks newline > <) to inject commands on the peer. Fixes: - internal/runtime/runtime.go: add shellQuote helper (mirrors internal/sshpush.shellQuote; duplicated to avoid import cycle). - internal/runtime/podman.go: Start quotes name + cmdStr; Stop/rm/ inspect quote name (defense-in-depth). - internal/runtime/wasm.go: Start uses env 'ORCA_ALLOC_ID=<id>' (so the UUID-style alloc ID is safely assigned) and shellQuote(cmdStr). Tests: 21 new injection regression tests (10 podman + 9 wasm + 2 image) covering ; && | $() backticks newline $IFS > < (). All pass. Existing runtime tests still pass. go vet + gofmt clean.
190 lines
6.6 KiB
Go
190 lines
6.6 KiB
Go
// Package runtime implements the runtime abstraction (REQ-078, I-B-006).
|
|
//
|
|
// The Runtime interface decouples the scheduler/CLI from the underlying
|
|
// execution backend. Five implementations are provided:
|
|
//
|
|
// - ProcessRuntime ("process") — wraps os/exec; LOCAL testing only.
|
|
// - PodmanRuntime ("podman") — SSH-push podman run on the peer.
|
|
// - WasmRuntime ("wasm") — wasmtime CLI via SSH (NO CGO; see
|
|
// C01_WASMTIME_CGO_EVAL.md for the C-01 grill gate evaluation).
|
|
// - PveVMRuntime ("pve-vm") — `qm` over SSH to a Proxmox peer.
|
|
// - PveCTRuntime ("pve-ct") — `pct` over SSH to a Proxmox peer.
|
|
//
|
|
// The Registry is keyed by the runtime.one_of frontmatter value. The
|
|
// Alloc carries a Runtime field that can change on migration (R-004).
|
|
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
)
|
|
|
|
// State is the lifecycle state of an alloc as observed by a Runtime.
|
|
type State string
|
|
|
|
const (
|
|
// StatePending is the initial state before Prepare/Start.
|
|
StatePending State = "pending"
|
|
// StateRunning means the runtime reports the workload as up.
|
|
StateRunning State = "running"
|
|
// StateStopped means the workload exited cleanly (Stop called
|
|
// or the process finished with exit 0).
|
|
StateStopped State = "stopped"
|
|
// StateFailed means the workload exited non-zero or could not
|
|
// be reached.
|
|
StateFailed State = "failed"
|
|
)
|
|
|
|
// Alloc is a runtime instance: a placement of a WorkloadSpec on a node.
|
|
// The Runtime field is the runtime.one_of value used to dispatch to the
|
|
// correct Runtime implementation; it can change on migration (R-004).
|
|
type Alloc struct {
|
|
ID string
|
|
Spec *jobspec.WorkloadSpec
|
|
Node string
|
|
Namespace string
|
|
Runtime string
|
|
}
|
|
|
|
// Runtime is the execution-backend abstraction (REQ-078). Each method
|
|
// takes a context for cancellation/timeout. Implementations wrap a
|
|
// different execution backend (process, podman, wasm, pve-vm, pve-ct).
|
|
//
|
|
// Prepare is idempotent; Start/Stop/Status operate on the prepared
|
|
// runtime. The returned PID from Start is best-effort (container
|
|
// runtimes return the container ID hash as a synthetic PID).
|
|
type Runtime interface {
|
|
// Prepare provisions prerequisites for the alloc (image pull,
|
|
// vm create, etc.). It is idempotent.
|
|
Prepare(ctx context.Context, alloc *Alloc) error
|
|
// Start launches the workload and returns a best-effort PID (or
|
|
// container/VM identifier encoded as a positive integer).
|
|
Start(ctx context.Context, alloc *Alloc) (pid int, err error)
|
|
// Stop terminates the workload, gracefully first then forcibly
|
|
// after a grace period.
|
|
Stop(ctx context.Context, alloc *Alloc) error
|
|
// Status reports the current State of the alloc.
|
|
Status(ctx context.Context, alloc *Alloc) (State, error)
|
|
}
|
|
|
|
// Registry maps runtime.one_of values to Runtime implementations. The
|
|
// zero value is NOT usable; construct one with NewRegistry.
|
|
type Registry struct {
|
|
runtimes map[string]Runtime
|
|
}
|
|
|
|
// NewRegistry returns an empty Registry.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{runtimes: make(map[string]Runtime)}
|
|
}
|
|
|
|
// Register adds a Runtime under the given one_of key (e.g. "process",
|
|
// "podman", "wasm", "pve-vm", "pve-ct"). Registering the same key twice
|
|
// replaces the prior implementation (last-wins) — this is intentional
|
|
// so tests can override.
|
|
func (r *Registry) Register(name string, rt Runtime) {
|
|
if r.runtimes == nil {
|
|
r.runtimes = make(map[string]Runtime)
|
|
}
|
|
r.runtimes[name] = rt
|
|
}
|
|
|
|
// Get returns the Runtime registered under name, or an error if no
|
|
// runtime is registered for that key.
|
|
func (r *Registry) Get(name string) (Runtime, error) {
|
|
rt, ok := r.runtimes[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("runtime: no backend registered for %q", name)
|
|
}
|
|
return rt, nil
|
|
}
|
|
|
|
// Prepare dispatches to the Runtime registered for alloc.Runtime. It
|
|
// returns an error if the runtime is unknown or Prepare fails.
|
|
func (r *Registry) Prepare(ctx context.Context, alloc *Alloc) error {
|
|
rt, err := r.Get(alloc.Runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rt.Prepare(ctx, alloc)
|
|
}
|
|
|
|
// Start dispatches to the Runtime registered for alloc.Runtime.
|
|
func (r *Registry) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
|
rt, err := r.Get(alloc.Runtime)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return rt.Start(ctx, alloc)
|
|
}
|
|
|
|
// Stop dispatches to the Runtime registered for alloc.Runtime.
|
|
func (r *Registry) Stop(ctx context.Context, alloc *Alloc) error {
|
|
rt, err := r.Get(alloc.Runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rt.Stop(ctx, alloc)
|
|
}
|
|
|
|
// Status dispatches to the Runtime registered for alloc.Runtime.
|
|
func (r *Registry) Status(ctx context.Context, alloc *Alloc) (State, error) {
|
|
rt, err := r.Get(alloc.Runtime)
|
|
if err != nil {
|
|
return StateFailed, err
|
|
}
|
|
return rt.Status(ctx, alloc)
|
|
}
|
|
|
|
// Names returns the registered runtime keys (unsorted).
|
|
func (r *Registry) Names() []string {
|
|
out := make([]string, 0, len(r.runtimes))
|
|
for k := range r.runtimes {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// commandFor returns the command string to run for an alloc. If the
|
|
// alloc has a top-level Runtime block with a Command, that is used.
|
|
// Otherwise the first task's command is used (task-group allocs, P06).
|
|
// Returns ("", error) if no command can be derived.
|
|
func commandFor(alloc *Alloc) (string, error) {
|
|
if alloc == nil || alloc.Spec == nil {
|
|
return "", fmt.Errorf("runtime: nil alloc or spec")
|
|
}
|
|
if alloc.Spec.Runtime != nil && alloc.Spec.Runtime.Command != "" {
|
|
return alloc.Spec.Runtime.Command, nil
|
|
}
|
|
if len(alloc.Spec.Tasks) > 0 && alloc.Spec.Tasks[0].Command != "" {
|
|
return alloc.Spec.Tasks[0].Command, nil
|
|
}
|
|
return "", fmt.Errorf("runtime: alloc %s has no command", alloc.ID)
|
|
}
|
|
|
|
// imageFor returns the image/wasm-file path for an alloc (podman/wasm).
|
|
func imageFor(alloc *Alloc) (string, error) {
|
|
if alloc == nil || alloc.Spec == nil || alloc.Spec.Runtime == nil {
|
|
return "", fmt.Errorf("runtime: nil alloc/spec/runtime")
|
|
}
|
|
if alloc.Spec.Runtime.Image == "" {
|
|
return "", fmt.Errorf("runtime: alloc %s has no image", alloc.ID)
|
|
}
|
|
return alloc.Spec.Runtime.Image, nil
|
|
}
|
|
|
|
// shellQuote single-quotes a string for safe shell interpolation over
|
|
// SSH exec. It escapes embedded single-quotes via the standard '\”
|
|
// idiom (POSIX shell). This mirrors internal/sshpush.shellQuote; the
|
|
// helper is duplicated to avoid an import cycle (sshpush is a leaf
|
|
// transport package, runtime depends on it but does not access its
|
|
// private helpers). Used to safely interpolate jobspec-supplied
|
|
// command strings into remote shell commands (REQ-119, F3 — command
|
|
// injection hardening).
|
|
func shellQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
|
}
|