Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf3d98eb2b | |||
| 4b70e31cf4 | |||
| b0158c96e9 | |||
| 7479cd1534 |
@@ -1,26 +1,21 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "grill",
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.13",
|
||||
"milestone_slug": "production-hardening-2",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-07T19:15:00Z",
|
||||
"updated_at": "2026-08-07T19:05:00Z",
|
||||
"milestone_complete": false,
|
||||
"previous_milestone": "v0.12",
|
||||
"phase_count": 14,
|
||||
"phases_shipped": [],
|
||||
"tags_shipped": [],
|
||||
"phases_shipped": ["P0", "P1"],
|
||||
"tags_shipped": ["v0.12.0", "v0.12.1"],
|
||||
"requirements": {
|
||||
"covered": [],
|
||||
"covered": [149],
|
||||
"partial": []
|
||||
},
|
||||
"binding_conditions": [
|
||||
"C-39", "C-40", "C-41", "C-42", "C-43",
|
||||
"C-44", "C-45", "C-46", "C-47", "C-48", "C-49"
|
||||
],
|
||||
"binding_conditions": ["C-39","C-40","C-41","C-42","C-43","C-44","C-45","C-46","C-47","C-48","C-49"],
|
||||
"load_bearing_rule": "R-022",
|
||||
"next_milestone": "v1.0",
|
||||
"grill_verdict": "CONDITIONAL_PROCEED",
|
||||
"grill_confidence": 0.82
|
||||
"next_milestone": "v1.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module git.cloudinit.dev/coreci/orca
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.12
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
|
||||
@@ -299,10 +299,16 @@ func Restore(opts RestoreOptions) error {
|
||||
return fmt.Errorf("restore: read tar entry: %w", err)
|
||||
}
|
||||
name := filepath.FromSlash(hdr.Name)
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "..") {
|
||||
return fmt.Errorf("restore: unsafe path %q", hdr.Name)
|
||||
}
|
||||
// F3: tar-slip containment check. The prior prefix check
|
||||
// (HasPrefix "/" || "..") missed patterns like "a/../../etc".
|
||||
// Resolve the destination and verify it stays within target
|
||||
// via filepath.Rel; reject if the relative path escapes (starts
|
||||
// with ".." or is absolute).
|
||||
dest := filepath.Join(target, name)
|
||||
rel, err := filepath.Rel(target, dest)
|
||||
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return fmt.Errorf("restore: unsafe path %q escapes target (F3: tar-slip)", hdr.Name)
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(dest, os.FileMode(hdr.Mode)); err != nil {
|
||||
|
||||
@@ -391,6 +391,71 @@ func TestRestoreRejectsTraversalSymlink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// createCraftedTarballWithFile creates a tar.gz containing a single
|
||||
// regular file entry with the given (possibly malicious) name. Used to
|
||||
// test the tar-slip path-traversal guard (F3).
|
||||
func createCraftedTarballWithFile(path, name, body string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Typeflag: tar.TypeReg,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(body)),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRestoreRejectsTarSlipRegularFile verifies a tarball with a regular
|
||||
// file entry whose name contains an embedded ".." traversal (e.g.
|
||||
// "a/../../etc/passwd") is rejected. The old prefix-only check missed
|
||||
// this pattern; the F3 filepath.Rel containment check catches it.
|
||||
func TestRestoreRejectsTarSlipRegularFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tarPath := filepath.Join(dir, "slip.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarballWithFile(tarPath, "a/../../etc/passwd", "pwned"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 9)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject tar-slip regular file (F3)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe path") {
|
||||
t.Errorf("error should mention unsafe path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createCraftedTarball creates a tar.gz containing a single symlink
|
||||
// entry with the given linkname. Used to test symlink validation.
|
||||
func createCraftedTarball(path, name, linkname string) error {
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ func parseIdentity(raw string) (acl.Identity, error) {
|
||||
if raw == "" {
|
||||
return acl.Identity{}, fmt.Errorf("identity is empty")
|
||||
}
|
||||
return acl.Identity{Kind: acl.KindToken, ID: raw}, nil
|
||||
return acl.Identity{Kind: acl.KindOidc, ID: raw}, nil
|
||||
}
|
||||
|
||||
// parsePermissions parses a comma-separated list of "read","write",
|
||||
|
||||
@@ -55,13 +55,13 @@ func TestParseIdentity_Spiffe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIdentity_Token(t *testing.T) {
|
||||
func TestParseIdentity_Oidc(t *testing.T) {
|
||||
id, err := parseIdentity("operator-1")
|
||||
if err != nil {
|
||||
t.Fatalf("parseIdentity: %v", err)
|
||||
}
|
||||
if id.Kind != "token" {
|
||||
t.Errorf("kind = %q, want token", id.Kind)
|
||||
if id.Kind != "oidc" {
|
||||
t.Errorf("kind = %q, want oidc", id.Kind)
|
||||
}
|
||||
if id.ID != "operator-1" {
|
||||
t.Errorf("id = %q, want operator-1", id.ID)
|
||||
|
||||
@@ -36,9 +36,9 @@ if any peer fails.`,
|
||||
}
|
||||
|
||||
type noOrcaPeerResult struct {
|
||||
Node string `json:"node"`
|
||||
Peer string `json:"peer"`
|
||||
Pass bool `json:"pass"`
|
||||
Node string `json:"node"`
|
||||
Peer string `json:"peer"`
|
||||
Pass bool `json:"pass"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -192,12 +192,12 @@ Reports: which peers are on which version, any compatibility issues.`,
|
||||
}
|
||||
|
||||
type compatPeerResult struct {
|
||||
Node string `json:"node"`
|
||||
Peer string `json:"peer"`
|
||||
Version string `json:"version"`
|
||||
LeadVersion string `json:"lead_version,omitempty"`
|
||||
Compatible bool `json:"compatible"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Peer string `json:"peer"`
|
||||
Version string `json:"version"`
|
||||
LeadVersion string `json:"lead_version,omitempty"`
|
||||
Compatible bool `json:"compatible"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
}
|
||||
|
||||
func runCompatCheck(cmd *cobra.Command) error {
|
||||
@@ -261,13 +261,13 @@ func runCompatCheck(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
summary := map[string]any{
|
||||
"lead_version": leadVersion,
|
||||
"schema_version": emit.SchemaVersion,
|
||||
"results": results,
|
||||
"versions_seen": versionSet,
|
||||
"issues": issues,
|
||||
"schema_ok": schemaOK,
|
||||
"manifest_ok": manifestOK,
|
||||
"lead_version": leadVersion,
|
||||
"schema_version": emit.SchemaVersion,
|
||||
"results": results,
|
||||
"versions_seen": versionSet,
|
||||
"issues": issues,
|
||||
"schema_ok": schemaOK,
|
||||
"manifest_ok": manifestOK,
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
@@ -396,7 +396,10 @@ func verifyTxnManifestCompat(ctx context.Context, ex drainExecer, nodes []*model
|
||||
if first == "" {
|
||||
continue
|
||||
}
|
||||
man, err := ex.Exec(ctx, peer, fmt.Sprintf("cat /etc/orca/cluster/txns/%s/manifest.json 2>/dev/null || true", first))
|
||||
// F7: first is a directory name parsed from remote `ls` output
|
||||
// and is therefore attacker-controlled (stored injection from a
|
||||
// malicious peer). Shell-quote it before interpolation.
|
||||
man, err := ex.Exec(ctx, peer, fmt.Sprintf("cat /etc/orca/cluster/txns/%s/manifest.json 2>/dev/null || true", sshQuote(first)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -414,4 +417,3 @@ func verifyTxnManifestCompat(ctx context.Context, ex drainExecer, nodes []*model
|
||||
func sshQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -74,8 +74,8 @@ func splitHostPort(addr string) (string, string, bool) {
|
||||
}
|
||||
|
||||
var (
|
||||
drainTimeout time.Duration
|
||||
migrateTarget string
|
||||
drainTimeout time.Duration
|
||||
migrateTarget string
|
||||
)
|
||||
|
||||
// allocUnit is the systemd unit name pattern for orca allocations.
|
||||
@@ -128,7 +128,15 @@ func listRunningAllocs(ctx context.Context, ex drainExecer, peer string) ([]stri
|
||||
// stopAlloc sends `systemctl stop orca-alloc-<id>.service` to a node.
|
||||
// A unit that is already stopped (or never existed) is treated as
|
||||
// success: drain is idempotent.
|
||||
//
|
||||
// F6: allocID is parsed from remote `systemctl list-units` output and is
|
||||
// therefore attacker-controlled (a malicious peer could emit a crafted
|
||||
// unit name). Validate against ^[A-Za-z0-9_-]+$ before interpolation into
|
||||
// the shell command to prevent stored command injection.
|
||||
func stopAlloc(ctx context.Context, ex drainExecer, peer, allocID string) error {
|
||||
if !validSafeName(allocID) {
|
||||
return fmt.Errorf("stopAlloc: invalid alloc id %q (allowed: A-Z a-z 0-9 _ -)", allocID)
|
||||
}
|
||||
cmd := fmt.Sprintf("systemctl stop %s", allocUnit(allocID))
|
||||
_, err := ex.Exec(ctx, peer, cmd)
|
||||
if err != nil {
|
||||
@@ -554,8 +562,8 @@ is named <name>-migrated-<timestamp>.`,
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"job": jobName,
|
||||
"target": target.Name,
|
||||
"job": jobName,
|
||||
"target": target.Name,
|
||||
"already_on_target": len(onTarget) > 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ func TestInit_FullBootstrap(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration version: %v", err)
|
||||
}
|
||||
if version != "0007_certs_serial_unique.sql" {
|
||||
t.Errorf("migration version = %q, want 0007_certs_serial_unique.sql", version)
|
||||
if version != "0008_audit_tamper_evidence.sql" {
|
||||
t.Errorf("migration version = %q, want 0008_audit_tamper_evidence.sql", version)
|
||||
}
|
||||
|
||||
// Verify localhost node registered with kind=localhost.
|
||||
|
||||
+74
-24
@@ -60,16 +60,21 @@ var jobRunCmd = &cobra.Command{
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec, closer, err := jobExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// If --target or --idempotency-key is set, route through the
|
||||
// dispatcher (which may land the job locally or on a peer
|
||||
// based on capacity).
|
||||
if runTarget != "" || runIDKey != "" {
|
||||
// v0.13 phase-03 scheduler wiring (REQ-151, C-44): decide
|
||||
// whether to run locally (dev mode / no remote nodes) or
|
||||
// remotely (scheduler picks a peer, render systemd, SSH-push).
|
||||
// The deprecated mTLS Dispatcher path (--idempotency-key) is
|
||||
// retained only for the dual-write window; the new remote path
|
||||
// uses the CLI-side scheduler + sshpush.
|
||||
if runIDKey != "" {
|
||||
// Legacy --idempotency-key dispatch path (deprecated mTLS
|
||||
// Dispatcher). Retained for backward compat; routes through
|
||||
// engine.Dispatcher which is scheduled for removal in v0.10.
|
||||
exec, closer, err := jobExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
db, dbCloser, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -95,25 +100,70 @@ var jobRunCmd = &cobra.Command{
|
||||
return nil
|
||||
}
|
||||
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Name,
|
||||
Spec: args[0],
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
if err := exec.Run(ctx, job, workloadToTaskSpecs(spec)); err != nil {
|
||||
res, nodesByHost, err := dispatchDecision(ctx, spec, runTarget)
|
||||
if err != nil {
|
||||
logDispatch(nil, err)
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
||||
return err
|
||||
_ = printJSON(map[string]any{"status": "failed", "error": err.Error()})
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "✗ Job %s failed: %v\n", job.ID, err)
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": job.ID, "name": job.Name, "status": "complete"})
|
||||
|
||||
switch res.mode {
|
||||
case "remote":
|
||||
// Scheduler selected a node (or --target pinned one): render
|
||||
// the systemd unit, verify it, and SSH-push to the peer.
|
||||
// C-44: a push failure is an error (no local fallback).
|
||||
unitPaths, derr := deployRemote(ctx, spec, res, nodesByHost)
|
||||
logDispatch(res, derr)
|
||||
if derr != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"status": "failed", "node": res.node, "error": derr.Error()})
|
||||
}
|
||||
return derr
|
||||
}
|
||||
res.unitPaths = unitPaths
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"status": "deployed",
|
||||
"node": res.node,
|
||||
"alloc_id": res.allocID,
|
||||
"units": unitPaths,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job deployed to %s: %s (%s)\n", res.node, spec.Name, strings.Join(unitPaths, ", "))
|
||||
return nil
|
||||
|
||||
case "local":
|
||||
// Local exec fallback (dev mode: no remote nodes registered).
|
||||
exec, closer, err := jobExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Name,
|
||||
Spec: args[0],
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
runErr := exec.Run(ctx, job, workloadToTaskSpecs(spec))
|
||||
logDispatch(res, runErr)
|
||||
if runErr != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": runErr.Error()})
|
||||
return runErr
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "✗ Job %s failed: %v\n", job.ID, runErr)
|
||||
return runErr
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": job.ID, "name": job.Name, "status": "complete"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job complete: %s (%s)\n", job.ID, job.Name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job complete: %s (%s)\n", job.ID, job.Name)
|
||||
return nil
|
||||
return fmt.Errorf("job run: unknown dispatch mode %q", res.mode)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
// Package cli: job_dispatch.go wires the v0.9 CLI-side scheduler
|
||||
// (internal/scheduler), the systemd emitter (internal/emitter), and the
|
||||
// SSH-push transport (internal/sshpush) into `orca job run`
|
||||
// (REQ-151, binding condition C-44, v0.13 milestone phase 03).
|
||||
//
|
||||
// The dispatch flow (replacing the deprecated mTLS Dispatcher path) is:
|
||||
//
|
||||
// 1. Load registered nodes from the orca registry (DB) and project them
|
||||
// into scheduler.NodeInfo + a hostname->model.Node map for SSH-push.
|
||||
// 2. If --target is set, pin to that node directly (manual override).
|
||||
// 3. If no --target and no remote nodes are registered (only localhost
|
||||
// or none), fall back to local exec (backward compat for dev mode).
|
||||
// 4. If no --target and remote nodes ARE registered, invoke
|
||||
// scheduler.Schedule -> pick the best node -> render the systemd unit
|
||||
// via internal/emitter -> systemd-analyze verify (when available) ->
|
||||
// SSH-push the unit to the target via internal/sshpush.
|
||||
//
|
||||
// C-44 (binding condition): if the scheduler selects a node but the
|
||||
// SSH-push FAILS, return an error. Do NOT silently fall back to local
|
||||
// execution. Local fallback is ONLY when len(registeredRemoteNodes)==0.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/scheduler"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// jobDispatchTransport is the SSH-push surface `job run` needs for
|
||||
// remote deployment. *sshpush.Transport satisfies it; tests substitute
|
||||
// a mock (same pattern as txn.go / job_verify.go).
|
||||
type jobDispatchTransport interface {
|
||||
WriteFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error
|
||||
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// jobDispatchTransportOverride is the package-level seam. When non-nil
|
||||
// it replaces the production transport; tests set it and restore nil.
|
||||
var jobDispatchTransportOverride jobDispatchTransport
|
||||
|
||||
// jobDispatchTransportFromCtx returns the active SSH-push transport.
|
||||
// Tests override via jobDispatchTransportOverride; production builds a
|
||||
// real *sshpush.Transport from the orca SSH key + known_hosts paths.
|
||||
func jobDispatchTransportFromCtx() (jobDispatchTransport, error) {
|
||||
if jobDispatchTransportOverride != nil {
|
||||
return jobDispatchTransportOverride, nil
|
||||
}
|
||||
keyPath := certpaths.SSHKeyPath()
|
||||
khPath := certpaths.KnownHostsPath()
|
||||
return sshpush.NewTransport(keyPath, khPath), nil
|
||||
}
|
||||
|
||||
// dispatchResult is the outcome of a `job run` dispatch decision.
|
||||
type dispatchResult struct {
|
||||
// mode is "local" (local exec fallback) or "remote" (scheduled +
|
||||
// SSH-pushed to a peer).
|
||||
mode string
|
||||
// node is the hostname of the selected/pinned node (remote only).
|
||||
node string
|
||||
// allocID is the scheduler allocation id (remote only).
|
||||
allocID string
|
||||
// unitPaths is the list of systemd unit paths written (remote only).
|
||||
unitPaths []string
|
||||
}
|
||||
|
||||
// dispatchDecision decides how `job run` should execute the spec:
|
||||
//
|
||||
// - "local" -> run via the local executor (dev mode / no remote nodes)
|
||||
// - "remote" -> render + SSH-push the systemd unit to the chosen node
|
||||
//
|
||||
// It loads registered nodes from the DB, projects them into
|
||||
// scheduler.NodeInfo, and consults the scheduler when no --target is
|
||||
// set. Returns a dispatchResult describing the chosen path; the caller
|
||||
// performs the actual execution.
|
||||
//
|
||||
// C-44: when remote nodes are registered, a scheduling failure returns
|
||||
// an error (no local fallback). The local fallback ONLY happens when
|
||||
// there are zero remote nodes registered (only localhost or none).
|
||||
func dispatchDecision(ctx context.Context, spec *jobspec.WorkloadSpec, target string) (*dispatchResult, map[string]*model.Node, error) {
|
||||
if spec == nil {
|
||||
return nil, nil, errors.New("dispatch: nil spec")
|
||||
}
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dispatch: open db: %w", err)
|
||||
}
|
||||
defer closer()
|
||||
|
||||
nodeRepo := store.NewNodeRepo(db)
|
||||
capRepo := store.NewCapacityRepo(db)
|
||||
nodes, err := nodeRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dispatch: list nodes: %w", err)
|
||||
}
|
||||
caps, err := capRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dispatch: list capacity: %w", err)
|
||||
}
|
||||
capByNode := make(map[string]*store.NodeCapacity, len(caps))
|
||||
for _, c := range caps {
|
||||
capByNode[c.NodeID] = c
|
||||
}
|
||||
|
||||
// Project registered nodes into scheduler.NodeInfo. A node counts
|
||||
// as a "remote" scheduling candidate when it is ready and is NOT
|
||||
// the localhost node (kind=localhost). localhost is excluded from
|
||||
// the candidate set so the scheduler only considers real peers;
|
||||
// when the candidate set is empty we fall back to local exec.
|
||||
var candidates []scheduler.NodeInfo
|
||||
remoteNodes := make(map[string]*model.Node) // hostname -> node
|
||||
for _, n := range nodes {
|
||||
if n.State != model.NodeStateReady {
|
||||
continue
|
||||
}
|
||||
if n.Kind == string(model.NodeKindLocalhost) {
|
||||
continue
|
||||
}
|
||||
ni := nodeToNodeInfo(n, capByNode[n.ID])
|
||||
candidates = append(candidates, ni)
|
||||
remoteNodes[ni.Hostname] = n
|
||||
}
|
||||
|
||||
// --target override: pin to the named node. The target may be a
|
||||
// node ID, name, or hostname. We resolve it against the registered
|
||||
// nodes (including localhost when explicitly targeted).
|
||||
if strings.TrimSpace(target) != "" {
|
||||
chosen, err := resolveTargetNode(ctx, nodeRepo, target)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hostname := chosen.Name
|
||||
if hostname == "" {
|
||||
hostname = chosen.ID
|
||||
}
|
||||
// Even a localhost target goes through the remote push path
|
||||
// when explicitly pinned (the operator asked for it).
|
||||
remoteNodes[hostname] = chosen
|
||||
return &dispatchResult{
|
||||
mode: "remote",
|
||||
node: hostname,
|
||||
allocID: allocIDFor(spec, 0),
|
||||
}, remoteNodes, nil
|
||||
}
|
||||
|
||||
// No remote nodes registered -> local exec fallback (dev mode).
|
||||
if len(candidates) == 0 {
|
||||
return &dispatchResult{mode: "local"}, remoteNodes, nil
|
||||
}
|
||||
|
||||
// Remote nodes registered -> invoke the scheduler. A scheduling
|
||||
// failure is an error (C-44: no silent local fallback).
|
||||
placements, err := scheduler.Schedule(candidates, scheduler.WorkloadRequest{
|
||||
Spec: spec,
|
||||
Namespace: "default",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dispatch: schedule: %w", err)
|
||||
}
|
||||
if len(placements) == 0 {
|
||||
return nil, nil, fmt.Errorf("dispatch: scheduler returned no placements for %q", spec.Name)
|
||||
}
|
||||
// Job/DaemonSet produce one-or-many placements; for `job run` we
|
||||
// deploy the first placement (the best-fit node). Multi-replica
|
||||
// Service fan-out is handled by the txn/apply path, not job run.
|
||||
p := placements[0]
|
||||
return &dispatchResult{
|
||||
mode: "remote",
|
||||
node: p.Node,
|
||||
allocID: p.AllocID,
|
||||
}, remoteNodes, nil
|
||||
}
|
||||
|
||||
// deployRemote renders the systemd unit for the spec on the chosen
|
||||
// node, runs systemd-analyze verify (when available), and SSH-pushes
|
||||
// the unit files to the peer. Returns the list of unit paths written.
|
||||
//
|
||||
// C-44: any render/verify/push failure is returned as an error; the
|
||||
// caller must NOT fall back to local exec.
|
||||
func deployRemote(ctx context.Context, spec *jobspec.WorkloadSpec, res *dispatchResult, nodesByHost map[string]*model.Node) ([]string, error) {
|
||||
if res == nil || res.mode != "remote" {
|
||||
return nil, errors.New("deployRemote: not a remote dispatch")
|
||||
}
|
||||
node, ok := nodesByHost[res.node]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("deployRemote: selected node %q not found in registry", res.node)
|
||||
}
|
||||
|
||||
// Render the systemd unit via the emitter. The runtime is required
|
||||
// for the process emitter; a spec with no runtime has nothing to
|
||||
// ExecStart and is rejected by the emitter.
|
||||
em := emitter.SystemdEmitter{}
|
||||
enode := &emitter.Node{
|
||||
Hostname: node.Name,
|
||||
Runtime: []string{"process"},
|
||||
Tags: nil,
|
||||
}
|
||||
// Advertise the node kind as a runtime so the emitter can branch
|
||||
// (proxmox nodes expose pve-* runtimes). For process workloads
|
||||
// this is informational.
|
||||
if node.Kind == string(model.NodeKindProxmox) {
|
||||
enode.Runtime = append(enode.Runtime, "proxmox")
|
||||
}
|
||||
files, err := em.Render(spec, enode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deployRemote: render unit: %w", err)
|
||||
}
|
||||
|
||||
// T9: systemd-analyze verify on the rendered unit before deploy.
|
||||
// Run it locally (the unit is a portable text file); if
|
||||
// systemd-analyze is not installed, skip silently (dev boxes
|
||||
// without systemd). A verification FAILURE is an error.
|
||||
for _, f := range files {
|
||||
if err := verifySystemdUnit(ctx, f.Path, f.Content); err != nil {
|
||||
return nil, fmt.Errorf("deployRemote: systemd-analyze verify %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SSH-push the unit files to the peer.
|
||||
peer := sshPeerFor(node)
|
||||
transport, err := jobDispatchTransportFromCtx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deployRemote: transport: %w", err)
|
||||
}
|
||||
defer transport.Close()
|
||||
|
||||
var written []string
|
||||
for _, f := range files {
|
||||
mode := os.FileMode(0o644)
|
||||
if f.Mode != "" {
|
||||
// f.Mode is an octal string like "0644".
|
||||
var m uint64
|
||||
if _, perr := fmt.Sscanf(f.Mode, "%o", &m); perr == nil {
|
||||
mode = os.FileMode(m)
|
||||
}
|
||||
}
|
||||
if err := transport.WriteFile(ctx, peer, f.Path, []byte(f.Content), mode); err != nil {
|
||||
// C-44: SSH-push failure -> error, NOT local fallback.
|
||||
return nil, fmt.Errorf("deployRemote: push %s to %s (%s): %w", f.Path, res.node, peer, err)
|
||||
}
|
||||
written = append(written, f.Path)
|
||||
}
|
||||
|
||||
// Reload systemd + enable the unit so it starts at boot. These are
|
||||
// best-effort; a failure here is surfaced but does not undo the
|
||||
// push (the unit is on disk). We use systemctl daemon-reload +
|
||||
// enable --now for each .service unit (.target units for task
|
||||
// groups are also enabled).
|
||||
for _, p := range written {
|
||||
if !strings.HasSuffix(p, ".service") && !strings.HasSuffix(p, ".target") {
|
||||
continue
|
||||
}
|
||||
if _, err := transport.Exec(ctx, peer, fmt.Sprintf("systemctl daemon-reload && systemctl enable --now %s", shellQuoteSystemd(p))); err != nil {
|
||||
return written, fmt.Errorf("deployRemote: enable %s on %s: %w", p, res.node, err)
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// verifySystemdUnit runs `systemd-analyze verify` on the rendered unit
|
||||
// content. The unit is written to a temp file (with its real basename)
|
||||
// so systemd-analyze resolves fragment paths correctly. When
|
||||
// systemd-analyze is not on PATH, the check is skipped (dev boxes
|
||||
// without systemd). A non-zero exit from systemd-analyze is an error.
|
||||
func verifySystemdUnit(ctx context.Context, unitPath, content string) error {
|
||||
bin, err := exec.LookPath("systemd-analyze")
|
||||
if err != nil {
|
||||
// systemd-analyze not available (e.g. macOS dev box, minimal
|
||||
// container). Skip verification rather than failing — the
|
||||
// render layer already validates the spec shape.
|
||||
return nil
|
||||
}
|
||||
base := unitPath
|
||||
if idx := strings.LastIndex(unitPath, "/"); idx >= 0 {
|
||||
base = unitPath[idx+1:]
|
||||
}
|
||||
// os.CreateTemp appends a random suffix that would strip the
|
||||
// .service/.target extension systemd-analyze needs to recognize the
|
||||
// unit. Create the temp file in a dedicated temp dir with the exact
|
||||
// basename so the extension is preserved.
|
||||
tmpDir, err := os.MkdirTemp("", "orca-verify-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
tmpPath := tmpDir + "/" + base
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write temp unit: %w", err)
|
||||
}
|
||||
|
||||
vctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(vctx, bin, "verify", tmpPath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Trim the temp path from the output so the error reads with
|
||||
// the real unit path.
|
||||
msg := strings.TrimSpace(string(out))
|
||||
msg = strings.ReplaceAll(msg, tmpPath, unitPath)
|
||||
return fmt.Errorf("systemd-analyze verify failed: %s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeToNodeInfo projects a registered model.Node (+ its capacity
|
||||
// declaration) into a scheduler.NodeInfo. Runtimes are derived from the
|
||||
// node kind (proxmox -> "proxmox"; else "process"). Tags are sourced
|
||||
// from node metadata["tags"] (comma-separated) when present. Capacity
|
||||
// is sourced from the NodeCapacity row when present (else zero, which
|
||||
// the scheduler treats as always-fits on the capacity axis).
|
||||
func nodeToNodeInfo(n *model.Node, cap *store.NodeCapacity) scheduler.NodeInfo {
|
||||
ni := scheduler.NodeInfo{
|
||||
Hostname: n.Name,
|
||||
Kind: n.Kind,
|
||||
}
|
||||
if ni.Kind == "" {
|
||||
ni.Kind = string(model.NodeKindLinux)
|
||||
}
|
||||
switch n.Kind {
|
||||
case string(model.NodeKindProxmox):
|
||||
ni.Runtimes = []string{"process", "proxmox"}
|
||||
default:
|
||||
ni.Runtimes = []string{"process"}
|
||||
}
|
||||
if tags := nodeMetadataTag(n, "tags"); tags != "" {
|
||||
for _, t := range strings.Split(tags, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
ni.Tags = append(ni.Tags, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cap != nil {
|
||||
ni.CPU = cap.CPUMillicores
|
||||
ni.Memory = cap.MemoryMiB
|
||||
ni.FreeCPU = cap.CPUMillicores
|
||||
ni.FreeMem = cap.MemoryMiB
|
||||
}
|
||||
return ni
|
||||
}
|
||||
|
||||
// nodeMetadataTag reads a key from the node's metadata map. Returns ""
|
||||
// when the metadata is nil or the key is absent.
|
||||
func nodeMetadataTag(n *model.Node, key string) string {
|
||||
if n == nil || n.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
return n.Metadata[key]
|
||||
}
|
||||
|
||||
// resolveTargetNode resolves a --target value (node ID, name, or
|
||||
// hostname) to a registered *model.Node. Returns an error when the
|
||||
// target is not found.
|
||||
func resolveTargetNode(ctx context.Context, repo *store.NodeRepo, target string) (*model.Node, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return nil, errors.New("resolveTargetNode: empty target")
|
||||
}
|
||||
// Try by ID first.
|
||||
if n, err := repo.Get(ctx, target); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
// Then by name.
|
||||
if n, err := repo.GetByName(ctx, target); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("resolveTargetNode: target node %q not found in registry", target)
|
||||
}
|
||||
|
||||
// sshPeerFor returns the host:port SSH peer address for a node. The
|
||||
// node's orca Address is the mTLS daemon port (host:8443); SSH uses a
|
||||
// different port. We derive the host from the orca Address and use the
|
||||
// SSH port from node metadata["ssh_port"] when present, else 22.
|
||||
func sshPeerFor(n *model.Node) string {
|
||||
host := n.Address
|
||||
if idx := strings.LastIndex(host, ":"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
// Strip an ipv6 bracket if present.
|
||||
host = strings.TrimPrefix(host, "[")
|
||||
host = strings.TrimSuffix(host, "]")
|
||||
port := "22"
|
||||
if n != nil && n.Metadata != nil {
|
||||
if p, ok := n.Metadata["ssh_port"]; ok && strings.TrimSpace(p) != "" {
|
||||
port = strings.TrimSpace(p)
|
||||
}
|
||||
}
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
// allocIDFor renders a stable allocation id for a spec index, matching
|
||||
// the scheduler's allocID format (ns/name-idx).
|
||||
func allocIDFor(spec *jobspec.WorkloadSpec, idx int) string {
|
||||
return fmt.Sprintf("default/%s-%d", spec.Name, idx)
|
||||
}
|
||||
|
||||
// shellQuoteSystemd single-quotes a path for safe shell interpolation
|
||||
// in the remote systemctl command. Mirrors sshpush.shellQuote.
|
||||
func shellQuoteSystemd(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// logDispatch records the dispatch decision to the structured logger.
|
||||
func logDispatch(res *dispatchResult, err error) {
|
||||
log := slog.Default()
|
||||
if res == nil {
|
||||
log.Info("job.dispatch", slog.String("event", "job.dispatch"), slog.String("mode", "error"), slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
attrs := []any{slog.String("event", "job.dispatch"), slog.String("mode", res.mode)}
|
||||
if res.node != "" {
|
||||
attrs = append(attrs, slog.String("node", res.node))
|
||||
}
|
||||
if res.allocID != "" {
|
||||
attrs = append(attrs, slog.String("alloc_id", res.allocID))
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.Any("error", err))
|
||||
}
|
||||
log.Info("job.dispatch", attrs...)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// mockDispatchTransport is a test double for jobDispatchTransport. It
|
||||
// records calls and returns configured errors. The zero value succeeds
|
||||
// for every call.
|
||||
type mockDispatchTransport struct {
|
||||
mu sync.Mutex
|
||||
writeCalls []mockDispatchWriteCall
|
||||
execCalls []mockDispatchExecCall
|
||||
writeErr error // returned by WriteFile (simulates C-44 push failure)
|
||||
execErr error
|
||||
closeCalled bool
|
||||
}
|
||||
|
||||
type mockDispatchWriteCall struct {
|
||||
Peer string
|
||||
Path string
|
||||
Content string
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
type mockDispatchExecCall struct {
|
||||
Peer string
|
||||
Cmd string
|
||||
}
|
||||
|
||||
func (m *mockDispatchTransport) WriteFile(ctx context.Context, peer, path string, content []byte, mode os.FileMode) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.writeCalls = append(m.writeCalls, mockDispatchWriteCall{Peer: peer, Path: path, Content: string(content), Mode: mode})
|
||||
return m.writeErr
|
||||
}
|
||||
|
||||
func (m *mockDispatchTransport) Exec(ctx context.Context, peer, cmd string) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.execCalls = append(m.execCalls, mockDispatchExecCall{Peer: peer, Cmd: cmd})
|
||||
return nil, m.execErr
|
||||
}
|
||||
|
||||
func (m *mockDispatchTransport) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.closeCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertRemoteNode registers a ready remote (non-localhost) node in the
|
||||
// test DB so the scheduler sees it as a candidate.
|
||||
func insertRemoteNode(t *testing.T, name, addr string) {
|
||||
t.Helper()
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := store.NewNodeRepo(db)
|
||||
if err := repo.Insert(context.Background(), &model.Node{
|
||||
ID: name,
|
||||
Name: name,
|
||||
Address: addr,
|
||||
State: model.NodeStateReady,
|
||||
JoinedAt: time.Now().UTC(),
|
||||
LastSeen: time.Now().UTC(),
|
||||
Kind: string(model.NodeKindLinux),
|
||||
OS: "linux",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeJobMDSpec writes a Markdown jobspec to a temp file and returns
|
||||
// the path.
|
||||
func writeJobMDSpec(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "spec.md")
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write spec: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
const mdJobTrue = "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: true-job\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" command: /bin/true\n" +
|
||||
"---\n# True\n\nRuns /bin/true.\n"
|
||||
|
||||
// TestREQ151_LocalFallbackNoRemoteNodes (T13): `job run` with no remote
|
||||
// nodes registered (only localhost or none) runs locally via the
|
||||
// executor. The output says "Job complete" (local), not "deployed".
|
||||
func TestREQ151_LocalFallbackNoRemoteNodes(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
spec := writeJobMDSpec(t, mdJobTrue)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "run", spec})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("job run local fallback: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Job complete") {
|
||||
t.Errorf("expected local 'Job complete' output, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "deployed") {
|
||||
t.Errorf("did not expect 'deployed' for local fallback, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_RemoteNodeScheduledAndPushed (T7): `job run` with a remote
|
||||
// node registered invokes the scheduler and SSH-pushes the unit. The
|
||||
// mock transport records the write and the output says "deployed".
|
||||
func TestREQ151_RemoteNodeScheduledAndPushed(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
resetRootFlags(t)
|
||||
|
||||
mock := &mockDispatchTransport{}
|
||||
prev := jobDispatchTransportOverride
|
||||
jobDispatchTransportOverride = mock
|
||||
defer func() { jobDispatchTransportOverride = prev }()
|
||||
|
||||
spec := writeJobMDSpec(t, mdJobTrue)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "run", spec})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("job run remote: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "deployed to worker-1") {
|
||||
t.Errorf("expected 'deployed to worker-1', got: %s", out)
|
||||
}
|
||||
if len(mock.writeCalls) == 0 {
|
||||
t.Errorf("expected SSH-push write calls, got 0")
|
||||
}
|
||||
// The unit path should be the orca-v1 systemd unit.
|
||||
wrote := false
|
||||
for _, c := range mock.writeCalls {
|
||||
if strings.HasSuffix(c.Path, "orca-v1-true-job.service") {
|
||||
wrote = true
|
||||
if !strings.Contains(c.Content, "ExecStart=/bin/true") {
|
||||
t.Errorf("unit content missing ExecStart:\n%s", c.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wrote {
|
||||
t.Errorf("no write to orca-v1-true-job.service; calls=%+v", mock.writeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_C44_PushFailureReturnsError (T14, binding condition
|
||||
// C-44): when the scheduler selects a remote node but SSH-push fails,
|
||||
// `job run` returns an error. It does NOT silently fall back to local
|
||||
// execution.
|
||||
func TestREQ151_C44_PushFailureReturnsError(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
resetRootFlags(t)
|
||||
|
||||
mock := &mockDispatchTransport{writeErr: errMockPush}
|
||||
prev := jobDispatchTransportOverride
|
||||
jobDispatchTransportOverride = mock
|
||||
defer func() { jobDispatchTransportOverride = prev }()
|
||||
|
||||
spec := writeJobMDSpec(t, mdJobTrue)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "run", spec})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for SSH-push failure (C-44), got nil")
|
||||
}
|
||||
out := buf.String()
|
||||
// Must NOT have fallen back to local execution.
|
||||
if strings.Contains(out, "Job complete") {
|
||||
t.Errorf("C-44 violation: silently fell back to local exec on push failure:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "push") {
|
||||
t.Errorf("error should mention push failure, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_TargetOverridesScheduler (T6): --target pins to the named
|
||||
// node, bypassing the scheduler bin-packing.
|
||||
func TestREQ151_TargetOverridesScheduler(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
// Register two remote nodes; --target forces the specific one
|
||||
// even if the scheduler would prefer the other.
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
insertRemoteNode(t, "worker-2", "10.0.0.6:8443")
|
||||
resetRootFlags(t)
|
||||
|
||||
mock := &mockDispatchTransport{}
|
||||
prev := jobDispatchTransportOverride
|
||||
jobDispatchTransportOverride = mock
|
||||
defer func() { jobDispatchTransportOverride = prev }()
|
||||
|
||||
spec := writeJobMDSpec(t, mdJobTrue)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "run", spec, "--target", "worker-2"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("job run --target: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "deployed to worker-2") {
|
||||
t.Errorf("expected --target to pin worker-2, got: %s", out)
|
||||
}
|
||||
// The push must go to worker-2's SSH peer (10.0.0.6:22).
|
||||
if len(mock.writeCalls) == 0 {
|
||||
t.Fatalf("expected SSH-push write calls, got 0")
|
||||
}
|
||||
for _, c := range mock.writeCalls {
|
||||
if !strings.HasPrefix(c.Peer, "10.0.0.6:") {
|
||||
t.Errorf("push peer = %q, want 10.0.0.6:* (worker-2)", c.Peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_SchedulerNoFittingNodeErrors (C-44): a remote node is
|
||||
// registered but the workload's runtime/constraint excludes it; the
|
||||
// scheduler returns an error (no local fallback).
|
||||
func TestREQ151_SchedulerNoFittingNodeErrors(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
resetRootFlags(t)
|
||||
|
||||
// A wasm workload cannot fit a process-only node.
|
||||
spec := writeJobMDSpec(t, "---\nkind: Job\nname: wjob\nruntime:\n one_of: wasm\n command: /bin/true\n---\nbody\n")
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "run", spec})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no-fitting node, got nil")
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "Job complete") {
|
||||
t.Errorf("C-44 violation: fell back to local exec when no node fit:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// errMockPush is the sentinel returned by the mock transport on push
|
||||
// failure.
|
||||
var errMockPush = &mockPushError{}
|
||||
|
||||
type mockPushError struct{}
|
||||
|
||||
func (e *mockPushError) Error() string { return "mock push failure" }
|
||||
|
||||
// TestREQ151_VerifySystemdUnitSkipsWhenNoSystemdAnalyse ensures the
|
||||
// T9 verify step is a no-op (not an error) when systemd-analyze is not
|
||||
// on PATH (common on dev/macOS test boxes).
|
||||
func TestREQ151_VerifySystemdUnitSkipsWhenNoSystemdAnalyse(t *testing.T) {
|
||||
// Save PATH and strip systemd-analyze if present. Most CI/dev
|
||||
// boxes don't have it; if they do, we remove it from PATH for
|
||||
// this test by pointing PATH at an empty dir.
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
err := verifySystemdUnit(context.Background(), "/etc/systemd/system/foo.service", "[Service]\nExecStart=/bin/true\n")
|
||||
if err != nil {
|
||||
t.Errorf("verifySystemdUnit should skip when systemd-analyze missing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_NodeToNodeInfoProjection verifies the projection from
|
||||
// model.Node + capacity into scheduler.NodeInfo.
|
||||
func TestREQ151_NodeToNodeInfoProjection(t *testing.T) {
|
||||
n := &model.Node{
|
||||
ID: "n1",
|
||||
Name: "worker-1",
|
||||
Address: "10.0.0.5:8443",
|
||||
Kind: string(model.NodeKindLinux),
|
||||
Metadata: map[string]string{
|
||||
"tags": "ssd,fast",
|
||||
},
|
||||
}
|
||||
cap := &store.NodeCapacity{NodeID: "n1", CPUMillicores: 4000, MemoryMiB: 8192}
|
||||
ni := nodeToNodeInfo(n, cap)
|
||||
if ni.Hostname != "worker-1" {
|
||||
t.Errorf("Hostname = %q, want worker-1", ni.Hostname)
|
||||
}
|
||||
if ni.Kind != "linux" {
|
||||
t.Errorf("Kind = %q, want linux", ni.Kind)
|
||||
}
|
||||
if ni.FreeCPU != 4000 || ni.FreeMem != 8192 {
|
||||
t.Errorf("FreeCPU=%d FreeMem=%d, want 4000/8192", ni.FreeCPU, ni.FreeMem)
|
||||
}
|
||||
if len(ni.Tags) != 2 || ni.Tags[0] != "ssd" || ni.Tags[1] != "fast" {
|
||||
t.Errorf("Tags = %v, want [ssd fast]", ni.Tags)
|
||||
}
|
||||
|
||||
// Proxmox node.
|
||||
pn := &model.Node{Name: "pve-1", Address: "10.0.0.9:8443", Kind: string(model.NodeKindProxmox)}
|
||||
pni := nodeToNodeInfo(pn, nil)
|
||||
if pni.Kind != "proxmox" {
|
||||
t.Errorf("Kind = %q, want proxmox", pni.Kind)
|
||||
}
|
||||
found := false
|
||||
for _, r := range pni.Runtimes {
|
||||
if r == "proxmox" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("proxmox node missing 'proxmox' runtime: %v", pni.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_SSHPeerFor verifies the SSH peer address derivation.
|
||||
func TestREQ151_SSHPeerFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr string
|
||||
meta map[string]string
|
||||
want string
|
||||
}{
|
||||
{"10.0.0.5:8443", nil, "10.0.0.5:22"},
|
||||
{"10.0.0.5:8443", map[string]string{"ssh_port": "2222"}, "10.0.0.5:2222"},
|
||||
{"host.example.com:8443", nil, "host.example.com:22"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
n := &model.Node{Address: c.addr, Metadata: c.meta}
|
||||
got := sshPeerFor(n)
|
||||
if got != c.want {
|
||||
t.Errorf("sshPeerFor(%q) = %q, want %q", c.addr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_DispatchDecisionLocal ensures dispatchDecision returns
|
||||
// "local" when no remote nodes are registered.
|
||||
func TestREQ151_DispatchDecisionLocal(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x", Count: 1, Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"}}
|
||||
res, _, err := dispatchDecision(context.Background(), spec, "")
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchDecision: %v", err)
|
||||
}
|
||||
if res.mode != "local" {
|
||||
t.Errorf("mode = %q, want local (no remote nodes)", res.mode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_DispatchDecisionRemote ensures dispatchDecision returns
|
||||
// "remote" when a remote node is registered and fits.
|
||||
func TestREQ151_DispatchDecisionRemote(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x", Count: 1, Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"}}
|
||||
res, nodes, err := dispatchDecision(context.Background(), spec, "")
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchDecision: %v", err)
|
||||
}
|
||||
if res.mode != "remote" {
|
||||
t.Errorf("mode = %q, want remote", res.mode)
|
||||
}
|
||||
if res.node != "worker-1" {
|
||||
t.Errorf("node = %q, want worker-1", res.node)
|
||||
}
|
||||
if _, ok := nodes["worker-1"]; !ok {
|
||||
t.Errorf("nodes map missing worker-1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_DispatchDecisionTarget ensures --target pins to the named
|
||||
// node even when no other remote nodes exist.
|
||||
func TestREQ151_DispatchDecisionTarget(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-9", "10.0.0.9:8443")
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x", Count: 1, Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"}}
|
||||
res, _, err := dispatchDecision(context.Background(), spec, "worker-9")
|
||||
if err != nil {
|
||||
t.Fatalf("dispatchDecision: %v", err)
|
||||
}
|
||||
if res.mode != "remote" || res.node != "worker-9" {
|
||||
t.Errorf("result = %+v, want remote/worker-9", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_DispatchDecisionTargetNotFound ensures a bad --target
|
||||
// returns an error (no fallback).
|
||||
func TestREQ151_DispatchDecisionTargetNotFound(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
insertRemoteNode(t, "worker-1", "10.0.0.5:8443")
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x", Count: 1, Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"}}
|
||||
_, _, err := dispatchDecision(context.Background(), spec, "no-such-node")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown --target, got nil")
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func runJobLint(path string) ([]lintFinding, error) {
|
||||
findings = append(findings, lintCEL(spec)...)
|
||||
findings = append(findings, lintBody(spec, ext)...)
|
||||
findings = append(findings, lintBestPractice(spec)...)
|
||||
findings = append(findings, lintAdvisoryFields(spec)...)
|
||||
|
||||
sortLint(findings)
|
||||
if countErrors(findings) > 0 {
|
||||
@@ -358,6 +359,51 @@ func lintBestPractice(spec *jobspec.WorkloadSpec) []lintFinding {
|
||||
return out
|
||||
}
|
||||
|
||||
// lintAdvisoryFields warns when a spec carries blocks that are parsed
|
||||
// and validated but NOT yet enforced by the scheduler/emitter in this
|
||||
// version (REQ-152/T4). Being honest about what is implemented avoids
|
||||
// operators relying on a field that is silently ignored. The warnings
|
||||
// are advisory (severity warning) and never block apply.
|
||||
func lintAdvisoryFields(spec *jobspec.WorkloadSpec) []lintFinding {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
var out []lintFinding
|
||||
if spec.Schedule != nil && strings.TrimSpace(spec.Schedule.Cron) != "" {
|
||||
out = append(out, lintFinding{
|
||||
Category: catBestPractice,
|
||||
Severity: severityWarning,
|
||||
Line: 0,
|
||||
Message: "field 'schedule.cron' is not enforced in this version; it is advisory only",
|
||||
})
|
||||
}
|
||||
if spec.Health != nil {
|
||||
out = append(out, lintFinding{
|
||||
Category: catBestPractice,
|
||||
Severity: severityWarning,
|
||||
Line: 0,
|
||||
Message: "field 'health' is not enforced in this version; it is advisory only",
|
||||
})
|
||||
}
|
||||
if spec.Update != nil {
|
||||
out = append(out, lintFinding{
|
||||
Category: catBestPractice,
|
||||
Severity: severityWarning,
|
||||
Line: 0,
|
||||
Message: "field 'update' is not enforced in this version; it is advisory only",
|
||||
})
|
||||
}
|
||||
if len(spec.Affinity) > 0 {
|
||||
out = append(out, lintFinding{
|
||||
Category: catBestPractice,
|
||||
Severity: severityWarning,
|
||||
Line: 0,
|
||||
Message: "field 'affinity' is not enforced in this version; it is advisory only",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortLint(f []lintFinding) {
|
||||
sort.SliceStable(f, func(i, j int) bool {
|
||||
si := severityRank(f[i].Severity)
|
||||
|
||||
@@ -308,3 +308,74 @@ func TestJobLintMissingFile(t *testing.T) {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLintDaemonSetValid(t *testing.T) {
|
||||
resetRootFlags(t)
|
||||
spec := writeMDSpec(t, "---\n"+
|
||||
"kind: DaemonSet\n"+
|
||||
"name: log-shipper\n"+
|
||||
"schedule:\n"+
|
||||
" mode: every-node\n"+
|
||||
"restart:\n"+
|
||||
" mode: service\n"+
|
||||
"runtime:\n"+
|
||||
" one_of: process\n"+
|
||||
" command: /usr/local/bin/log-shipper\n"+
|
||||
"---\n# Log shipper\n\nRuns on every node.\n")
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "lint", spec})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("job lint daemonset: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "0 error(s)") {
|
||||
t.Errorf("expected 0 errors for valid DaemonSet, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLintAdvisoryScheduleCron(t *testing.T) {
|
||||
resetRootFlags(t)
|
||||
spec := writeMDSpec(t, "---\n"+
|
||||
"kind: Job\n"+
|
||||
"name: nightly\n"+
|
||||
"schedule:\n"+
|
||||
" cron: \"0 2 * * *\"\n"+
|
||||
"runtime:\n"+
|
||||
" one_of: process\n"+
|
||||
" command: /bin/true\n"+
|
||||
"---\n# Nightly\n\nBackup.\n")
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "lint", spec})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("job lint: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "schedule.cron' is not enforced") {
|
||||
t.Errorf("expected advisory warning for schedule.cron, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "0 error(s)") {
|
||||
t.Errorf("expected 0 errors, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLintAdvisoryHealthUpdateAffinity(t *testing.T) {
|
||||
resetRootFlags(t)
|
||||
spec := writeMDSpec(t, validServiceMD)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"job", "lint", spec})
|
||||
_ = rootCmd.Execute()
|
||||
out := buf.String()
|
||||
// validServiceMD has health + update blocks; both are advisory.
|
||||
if !strings.Contains(out, "field 'health' is not enforced") {
|
||||
t.Errorf("expected advisory warning for health, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "field 'update' is not enforced") {
|
||||
t.Errorf("expected advisory warning for update, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -128,6 +128,13 @@ Ctrl-C cancels the fan-out via signal.NotifyContext.`,
|
||||
if logsAllNodes && logsNode != "" {
|
||||
return fmt.Errorf("--all-nodes and --node are mutually exclusive")
|
||||
}
|
||||
// F1: validate --job before interpolation into the journalctl
|
||||
// unit pattern. Go's %q does not escape backticks and bash
|
||||
// executes command substitution inside double quotes, so an
|
||||
// unvalidated job name is a remote RCE vector.
|
||||
if logsJob != "" && !validSafeName(logsJob) {
|
||||
return fmt.Errorf("logs: --job %q contains disallowed characters (allowed: A-Z a-z 0-9 _ -)", logsJob)
|
||||
}
|
||||
since, err := parseSince(logsSince)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -271,7 +278,9 @@ func streamNodeLines(ctx context.Context, ex logsExecer, n *model.Node, since ti
|
||||
unitPattern = "orca-alloc-" + job + "-*"
|
||||
}
|
||||
sinceStr := since.Format("2006-01-02 15:04:05")
|
||||
cmd := fmt.Sprintf("journalctl -u %q --since %q --output json --no-pager", unitPattern, sinceStr)
|
||||
// F1: shellQuote (single-quote wrap) instead of %q — %q does not
|
||||
// escape backticks, enabling command substitution in double quotes.
|
||||
cmd := fmt.Sprintf("journalctl -u %s --since %s --output json --no-pager", shellQuote(unitPattern), shellQuote(sinceStr))
|
||||
raw, err := ex.Exec(ctx, peer, cmd)
|
||||
if err != nil {
|
||||
slog.Default().Warn("logs: exec failed", "node", n.Name, "peer", peer, "error", err)
|
||||
|
||||
+14
-6
@@ -22,9 +22,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
nftShowPeer string
|
||||
nftDiffAgainst string
|
||||
nftRateLimitRate int
|
||||
nftShowPeer string
|
||||
nftDiffAgainst string
|
||||
nftRateLimitRate int
|
||||
nftCountryBlockCC string
|
||||
)
|
||||
|
||||
@@ -70,6 +70,11 @@ recorded at apply time). Reports per-rule diffs.`,
|
||||
if nftDiffAgainst == "" {
|
||||
return errors.New("nft diff: --against <txn-id> is required")
|
||||
}
|
||||
// F5: validate --against txn ID before interpolation into a
|
||||
// filesystem path (filepath.Join(paths.TxnDir(), txnID, ...)).
|
||||
if !validTxnID(nftDiffAgainst) {
|
||||
return fmt.Errorf("nft diff: --against %q is not a valid txn id (expected T-[0-9a-f]{16})", nftDiffAgainst)
|
||||
}
|
||||
t, err := nftTransportFromCtx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("nft transport: %w", err)
|
||||
@@ -132,8 +137,12 @@ orca nft country block add RU,CN`,
|
||||
}
|
||||
codes := strings.Split(ccList, ",")
|
||||
for _, c := range codes {
|
||||
if len(c) != 2 {
|
||||
return fmt.Errorf("nft country block add: %q is not a 2-letter country code", c)
|
||||
// F11: validate against ^[A-Z]{2}$ (two uppercase ASCII letters),
|
||||
// not just len==2. The old check accepted arbitrary 2-byte
|
||||
// strings (e.g. "RU" but also "; " or "$(") which could inject
|
||||
// nft syntax or shell metacharacters.
|
||||
if !validCountryCode(c) {
|
||||
return fmt.Errorf("nft country block add: %q is not a valid ISO-3166 alpha-2 country code (expected two uppercase letters)", c)
|
||||
}
|
||||
}
|
||||
t, err := nftTransportFromCtx()
|
||||
@@ -263,4 +272,3 @@ func quoteAll(in []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestNftDiffCmd_NoDrift(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"nft", "diff", "--against", "txn-123"})
|
||||
rootCmd.SetArgs([]string{"nft", "diff", "--against", "T-abcdef0123456789"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("nft diff: %v", err)
|
||||
}
|
||||
|
||||
+15
-1
@@ -84,6 +84,11 @@ Cluster-wide txns (no --namespace) require --force +
|
||||
scoped txns (--namespace <ns>) only touch that namespace.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// F4: validate txn ID before interpolation into a remote shell
|
||||
// command and filesystem path.
|
||||
if !validTxnID(args[0]) {
|
||||
return fmt.Errorf("txn apply: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
|
||||
}
|
||||
id := txn.TxnID(args[0])
|
||||
transport, err := txnTransportFromCtx()
|
||||
if err != nil {
|
||||
@@ -175,6 +180,10 @@ var txnShowCmd = &cobra.Command{
|
||||
Short: "Show txn details (desired state, manifest, status)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// F4: validate txn ID before interpolation into a filesystem path.
|
||||
if !validTxnID(args[0]) {
|
||||
return fmt.Errorf("txn show: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
|
||||
}
|
||||
id := args[0]
|
||||
dir := filepath.Join(paths.TxnDir(), id)
|
||||
manifestPath := filepath.Join(dir, "manifest.json")
|
||||
@@ -230,6 +239,11 @@ the manual rollback path; orca-pull.sh runs rollback automatically on
|
||||
verify failure.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// F4: validate txn ID before interpolation into a remote shell
|
||||
// command (bash <dir>/rollback.sh) and filesystem path.
|
||||
if !validTxnID(args[0]) {
|
||||
return fmt.Errorf("txn rollback: invalid txn id %q (expected T-[0-9a-f]{16})", args[0])
|
||||
}
|
||||
id := txn.TxnID(args[0])
|
||||
transport, err := txnTransportFromCtx()
|
||||
if err != nil {
|
||||
@@ -237,7 +251,7 @@ verify failure.`,
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
dir := "/run/orca/txns/" + string(id)
|
||||
cmdStr := fmt.Sprintf("bash %s/rollback.sh", dir)
|
||||
cmdStr := fmt.Sprintf("bash %s/rollback.sh", shellQuote(dir))
|
||||
out, err := transport.Exec(ctx, txnRollbackLead, cmdStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rollback %s on %s: %w (output: %s)", id, txnRollbackLead, err, string(out))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package cli: validate.go provides shared input-validation helpers for
|
||||
// CLI command arguments that are interpolated into remote shell commands
|
||||
// or filesystem paths (Phase 02 injection hardening, v0.13).
|
||||
//
|
||||
// These helpers enforce strict allowlists so that attacker-controlled
|
||||
// values (job names, txn IDs, alloc IDs, country codes) cannot reach
|
||||
// shell interpolation or path joins without matching a known-safe shape.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// safeNameRe matches the allowlist for shell-interpolated identifiers
|
||||
// (job names, alloc IDs): ASCII letters, digits, underscore, hyphen.
|
||||
// Used to prevent backtick/command-substitution and metacharacter
|
||||
// injection into remote shell commands.
|
||||
var safeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// txnIDRe matches the canonical orca transaction ID format: "T-" prefix
|
||||
// followed by exactly 16 lowercase hex digits. Used to validate txn IDs
|
||||
// before they are interpolated into filesystem paths or remote shell
|
||||
// commands (`orca txn rollback`, `orca nft diff --against`).
|
||||
var txnIDRe = regexp.MustCompile(`^T-[0-9a-f]{16}$`)
|
||||
|
||||
// countryCodeRe matches ISO-3166 alpha-2 country codes: exactly two
|
||||
// uppercase ASCII letters. Used by `orca nft country block add` before
|
||||
// codes are interpolated into the nft ruleset.
|
||||
var countryCodeRe = regexp.MustCompile(`^[A-Z]{2}$`)
|
||||
|
||||
// validSafeName reports whether s is a safe shell-interpolation
|
||||
// identifier (ASCII alphanumeric, underscore, hyphen only, non-empty).
|
||||
func validSafeName(s string) bool {
|
||||
return safeNameRe.MatchString(s)
|
||||
}
|
||||
|
||||
// validTxnID reports whether s matches the canonical orca txn ID format
|
||||
// (^T-[0-9a-f]{16}$).
|
||||
func validTxnID(s string) bool {
|
||||
return txnIDRe.MatchString(s)
|
||||
}
|
||||
|
||||
// validCountryCode reports whether s is a valid ISO-3166 alpha-2 code
|
||||
// (two uppercase letters).
|
||||
func validCountryCode(s string) bool {
|
||||
return countryCodeRe.MatchString(s)
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a string for safe shell interpolation over
|
||||
// SSH exec. It escapes embedded single-quotes via the standard '\” idiom
|
||||
// (POSIX shell). This is the cli-package copy of the helper duplicated
|
||||
// across runtime/identity/stepca/sshpush to avoid import cycles; it
|
||||
// hardens command interpolation against backtick/command-substitution
|
||||
// injection (Go's %q does NOT escape backticks, and bash executes
|
||||
// command substitution inside double quotes).
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -13,13 +13,21 @@ import (
|
||||
|
||||
// isLoopback reports whether the address binds to a loopback interface
|
||||
// (127.0.0.1, ::1, localhost). REQ-123: pprof must be loopback-only.
|
||||
//
|
||||
// An empty host (e.g. ":6060") binds ALL interfaces and is therefore
|
||||
// treated as NON-loopback (F2: loopback-bypass fix). Only an explicit
|
||||
// loopback IP or the "localhost" name is accepted.
|
||||
func isLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || host == "localhost" {
|
||||
// F2: empty host (":6060") binds all interfaces — reject.
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
@@ -29,18 +37,22 @@ func isLoopback(addr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// StartPprof starts the pprof HTTP server on addr. REQ-123: pprof is
|
||||
// unauthenticated and MUST bind to a loopback interface only; this is a
|
||||
// hard invariant (F2: the --pprof-allow-public override was a phantom flag
|
||||
// that was never implemented and has been removed — non-loopback binds are
|
||||
// always refused).
|
||||
func StartPprof(addr string, log *slog.Logger) (*http.Server, error) {
|
||||
if addr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// REQ-123: pprof must bind to loopback only. Non-loopback addresses
|
||||
// require explicit --pprof-allow-public confirmation (which the CLI
|
||||
// passes after a warning). We refuse non-loopback here by default.
|
||||
// REQ-123: pprof must bind to loopback only. This is a hard
|
||||
// invariant; there is no public-bind override.
|
||||
if !isLoopback(addr) {
|
||||
log.Error("pprof refuses non-loopback bind",
|
||||
slog.String("addr", addr),
|
||||
slog.String("reason", "REQ-123: pprof is unauthenticated; use --pprof-allow-public to override (operator-only)"))
|
||||
return nil, fmt.Errorf("pprof: refusing non-loopback bind %s (REQ-123; unauthenticated; use --pprof-allow-public)", addr)
|
||||
slog.String("reason", "REQ-123: pprof is unauthenticated; loopback-only is a hard invariant"))
|
||||
return nil, fmt.Errorf("pprof: refusing non-loopback bind %s (REQ-123; unauthenticated; loopback-only is a hard invariant)", addr)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -286,3 +287,16 @@ func TestStartPprof_LoopbackAccepted(t *testing.T) {
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartPprof_EmptyHostRefused verifies that an address with an empty
|
||||
// host (e.g. ":6060"), which binds ALL interfaces, is refused as
|
||||
// non-loopback (F2: loopback-bypass fix).
|
||||
func TestStartPprof_EmptyHostRefused(t *testing.T) {
|
||||
_, err := StartPprof(":6060", slog.Default())
|
||||
if err == nil {
|
||||
t.Error("StartPprof on \":6060\" should be refused (F2: empty host binds all interfaces)")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "non-loopback") {
|
||||
t.Errorf("error should mention non-loopback, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ func CertCA() Check {
|
||||
Description: "CA at ~/.orca with mode 0600/0644 (REQ-033)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
dir := certpaths.Dir()
|
||||
caCert := certpaths.CACertPath()
|
||||
if _, err := os.Stat(caCert); err != nil {
|
||||
return ResultFail, fmt.Sprintf("CA cert missing: %v", err)
|
||||
}
|
||||
if err := security.EnforceFileModes(dir); err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
|
||||
+75
-9
@@ -13,7 +13,8 @@
|
||||
// The ruleset defines:
|
||||
//
|
||||
// - table inet orca-ingress
|
||||
// - set orca_trusted_probes (ipv4_addr interval, default 127.0.0.1)
|
||||
// - set orca_trusted_probes_v4 (ipv4_addr interval, default 127.0.0.1)
|
||||
// - set orca_trusted_probes_v6 (ipv6_addr interval, default ::1)
|
||||
// - input chain (SYN-flood filter on :443)
|
||||
// - prerouting chain (DNAT :443->127.0.0.1:8443, :80->127.0.0.1:8080)
|
||||
// - forward chain (rate-limit meter on :443)
|
||||
@@ -25,6 +26,7 @@ package emitter
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -39,9 +41,11 @@ const nftConfigPath = "/etc/nftables.d/orca.nft"
|
||||
// All fields have safe defaults so a zero-value config renders a
|
||||
// working ruleset.
|
||||
type NftClusterConfig struct {
|
||||
// TrustedProbes is the list of source IPs exempt from the
|
||||
// TrustedProbes is the list of source IPs/CIDRs exempt from the
|
||||
// SYN-flood filter and rate-limit (monitoring probes, the orca
|
||||
// lead itself). Defaults to [127.0.0.1, ::1].
|
||||
// lead itself). Defaults to [127.0.0.1, ::1]. Each entry must
|
||||
// parse as a valid IP or CIDR via net.ParseIP / net.ParseCIDR or
|
||||
// RenderNftConfig returns an error (F9: ruleset injection guard).
|
||||
TrustedProbes []string
|
||||
// RateLimit is the per-source rate limit (packets/second) for the
|
||||
// forward-chain meter on :443. Defaults to 100.
|
||||
@@ -73,31 +77,79 @@ func (c NftClusterConfig) withDefaults() NftClusterConfig {
|
||||
// `#!/usr/sbin/nft -f` shebang (so `nft -f` applies it and so a
|
||||
// drift-check `nft -c -f` validates the syntax).
|
||||
//
|
||||
// Returns an error only when the config is internally inconsistent
|
||||
// (e.g. a negative rate, which the defaults already prevent).
|
||||
// Returns an error when the config is internally inconsistent (e.g. a
|
||||
// negative rate, which the defaults already prevent) or when a
|
||||
// TrustedProbes entry fails to parse as an IP or CIDR (F9: ruleset
|
||||
// injection hardening — unvalidated entries are written directly into
|
||||
// the nft ruleset and could inject arbitrary nft syntax).
|
||||
func (NftEmitter) RenderNftConfig(clusterConfig NftClusterConfig) ([]File, error) {
|
||||
if clusterConfig.RateLimit < 0 || clusterConfig.RateBurst < 0 {
|
||||
return nil, errors.New("emitter/nft: rate/burst must be non-negative")
|
||||
}
|
||||
cfg := clusterConfig.withDefaults()
|
||||
content := renderNftRuleset(cfg)
|
||||
// F9: validate every TrustedProbes entry before rendering. An
|
||||
// invalid entry is rejected with an error rather than written raw
|
||||
// into the ruleset (which would allow nft-syntax injection).
|
||||
v4, v6, err := partitionTrustedProbes(cfg.TrustedProbes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := renderNftRuleset(cfg, v4, v6)
|
||||
return []File{{Path: nftConfigPath, Content: content, Mode: "0644"}}, nil
|
||||
}
|
||||
|
||||
// partitionTrustedProbes validates each entry as an IP or CIDR and
|
||||
// partitions the list into IPv4 and IPv6 slices. Returns an error if
|
||||
// any entry is neither a valid IP nor a valid CIDR (F9).
|
||||
func partitionTrustedProbes(probes []string) (v4, v6 []string, err error) {
|
||||
for _, p := range probes {
|
||||
if p == "" {
|
||||
return nil, nil, fmt.Errorf("emitter/nft: empty trusted probe entry (F9: ruleset injection guard)")
|
||||
}
|
||||
if ip := net.ParseIP(p); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
v4 = append(v4, p)
|
||||
} else {
|
||||
v6 = append(v6, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, _, cidrErr := net.ParseCIDR(p); cidrErr == nil {
|
||||
// Determine address family from the CIDR prefix.
|
||||
ip := net.ParseIP(strings.Split(p, "/")[0])
|
||||
if ip != nil && ip.To4() != nil {
|
||||
v4 = append(v4, p)
|
||||
} else {
|
||||
v6 = append(v6, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, nil, fmt.Errorf("emitter/nft: trusted probe %q is not a valid IP or CIDR (F9: ruleset injection guard)", p)
|
||||
}
|
||||
return v4, v6, nil
|
||||
}
|
||||
|
||||
// renderNftRuleset builds the nft ruleset string. The shape is
|
||||
// documented in the package comment; the exact lines are load-bearing
|
||||
// for `orca doctor nft` (which greps the live table for them) and for
|
||||
// `nft -c -f` (which parses the syntax).
|
||||
func renderNftRuleset(cfg NftClusterConfig) string {
|
||||
//
|
||||
// F9: TrustedProbes are split into separate ipv4_addr and ipv6_addr
|
||||
// sets (orca_trusted_probes_v4 / orca_trusted_probes_v6) because the
|
||||
// prior single ipv4_addr set included ::1 (an IPv6 address), which is
|
||||
// a type mismatch nft rejects.
|
||||
func renderNftRuleset(cfg NftClusterConfig, v4, v6 []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#!/usr/sbin/nft -f\n\n")
|
||||
b.WriteString("flush table inet orca-ingress\n\n")
|
||||
b.WriteString("table inet orca-ingress {\n")
|
||||
b.WriteString("\tset orca_trusted_probes {\n")
|
||||
|
||||
// F9: split IPv4 and IPv6 trusted probes into separate typed sets.
|
||||
b.WriteString("\tset orca_trusted_probes_v4 {\n")
|
||||
b.WriteString("\t\ttype ipv4_addr\n")
|
||||
b.WriteString("\t\tflags interval\n")
|
||||
b.WriteString("\t\telements = { ")
|
||||
for i, p := range cfg.TrustedProbes {
|
||||
for i, p := range v4 {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
@@ -105,6 +157,20 @@ func renderNftRuleset(cfg NftClusterConfig) string {
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("\t}\n\n")
|
||||
|
||||
b.WriteString("\tset orca_trusted_probes_v6 {\n")
|
||||
b.WriteString("\t\ttype ipv6_addr\n")
|
||||
b.WriteString("\t\tflags interval\n")
|
||||
b.WriteString("\t\telements = { ")
|
||||
for i, p := range v6 {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(p)
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("\t}\n\n")
|
||||
|
||||
b.WriteString("\tchain input {\n")
|
||||
b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
|
||||
b.WriteString("\t\tct state invalid drop\n")
|
||||
|
||||
@@ -90,3 +90,48 @@ func TestNftEmitter_ShebangFirst(t *testing.T) {
|
||||
t.Errorf("shebang not first:\n%s", files[0].Content[:40])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNftEmitter_RejectsInvalidTrustedProbe verifies that a TrustedProbes
|
||||
// entry that is not a valid IP or CIDR is rejected (F9: ruleset injection
|
||||
// guard). An unvalidated entry written raw into the ruleset could inject
|
||||
// arbitrary nft syntax.
|
||||
func TestNftEmitter_RejectsInvalidTrustedProbe(t *testing.T) {
|
||||
bad := []string{
|
||||
"not-an-ip",
|
||||
"127.0.0.1; flush ruleset",
|
||||
"$(whoami)",
|
||||
"10.0.0.0/33", // invalid CIDR prefix
|
||||
}
|
||||
for _, b := range bad {
|
||||
_, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{TrustedProbes: []string{"127.0.0.1", b}})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid trusted probe %q, got nil", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNftEmitter_TrustedProbesSplitV4V6 verifies that IPv4 and IPv6
|
||||
// probes are rendered into separate typed sets (F9: the prior single
|
||||
// ipv4_addr set included ::1, an IPv6 address — a type mismatch).
|
||||
func TestNftEmitter_TrustedProbesSplitV4V6(t *testing.T) {
|
||||
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{TrustedProbes: []string{"10.0.0.5", "::1"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
c := files[0].Content
|
||||
if !strings.Contains(c, "set orca_trusted_probes_v4") {
|
||||
t.Errorf("missing v4 set:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "set orca_trusted_probes_v6") {
|
||||
t.Errorf("missing v6 set:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "type ipv6_addr") {
|
||||
t.Errorf("missing ipv6_addr type:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "10.0.0.5") {
|
||||
t.Errorf("missing 10.0.0.5 in v4 set:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "::1") {
|
||||
t.Errorf("missing ::1 in v6 set:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,10 @@ func renderTaskUnit(spec *jobspec.WorkloadSpec, task *jobspec.TaskGroupTask, rt
|
||||
b.WriteString(fmt.Sprintf("PartOf=%s\n", targetUnit))
|
||||
b.WriteString("\n[Service]\n")
|
||||
b.WriteString(fmt.Sprintf("ExecStart=%s\n", cmd))
|
||||
for _, line := range renderRestartDirectives(spec) {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, line := range (SocketEmitter{}).RenderSocketLines(spec) {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
@@ -201,6 +205,13 @@ func renderSystemdUnit(spec *jobspec.WorkloadSpec) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Service]\n")
|
||||
b.WriteString(fmt.Sprintf("ExecStart=%s\n", spec.Runtime.Command))
|
||||
// Restart policy (REQ-152/T3): translate spec.Restart into the
|
||||
// systemd Restart= / StartLimitBurst= / StartLimitIntervalSec=
|
||||
// (or RestartSec=) directives. See renderRestartDirectives.
|
||||
for _, line := range renderRestartDirectives(spec) {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
// Lifecycle: post_start → ExecStartPost (runs after start).
|
||||
for _, cmd := range lifecyclePostStart(spec) {
|
||||
b.WriteString(fmt.Sprintf("ExecStartPost=%s\n", cmd))
|
||||
@@ -237,3 +248,49 @@ func lifecyclePreStop(spec *jobspec.WorkloadSpec) []string {
|
||||
}
|
||||
return spec.Lifecycle.PreStop
|
||||
}
|
||||
|
||||
// renderRestartDirectives translates the spec.Restart block into the
|
||||
// systemd [Service]/[Unit] restart directives (REQ-152/T3):
|
||||
//
|
||||
// - never → Restart=no (explicit; omitted when Restart is nil)
|
||||
// - on-failure → Restart=on-failure + StartLimitBurst=<MaxRetries>
|
||||
// - service → Restart=always + StartLimitBurst=<MaxRetries> (when
|
||||
// MaxRetries > 0)
|
||||
//
|
||||
// The delay (a duration string like "5s") maps to StartLimitIntervalSec=
|
||||
// when set; for the on-failure/service modes a non-empty delay also
|
||||
// emits RestartSec=<delay> so systemd backs off between restart attempts.
|
||||
// A nil Restart block produces no directives (the caller's default
|
||||
// applies — for a [Service] with no Restart= that is Restart=no).
|
||||
func renderRestartDirectives(spec *jobspec.WorkloadSpec) []string {
|
||||
if spec.Restart == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
switch spec.Restart.Mode {
|
||||
case "never", "":
|
||||
out = append(out, "Restart=no")
|
||||
case "on-failure":
|
||||
out = append(out, "Restart=on-failure")
|
||||
if spec.Restart.MaxRetries > 0 {
|
||||
out = append(out, fmt.Sprintf("StartLimitBurst=%d", spec.Restart.MaxRetries))
|
||||
}
|
||||
case "service":
|
||||
out = append(out, "Restart=always")
|
||||
if spec.Restart.MaxRetries > 0 {
|
||||
out = append(out, fmt.Sprintf("StartLimitBurst=%d", spec.Restart.MaxRetries))
|
||||
}
|
||||
default:
|
||||
// Unknown mode: emit Restart=no so the unit is explicit and
|
||||
// systemd-analyze verify does not reject an unknown value.
|
||||
out = append(out, "Restart=no")
|
||||
}
|
||||
if spec.Restart.Delay != "" {
|
||||
switch spec.Restart.Mode {
|
||||
case "on-failure", "service":
|
||||
out = append(out, fmt.Sprintf("RestartSec=%s", spec.Restart.Delay))
|
||||
out = append(out, fmt.Sprintf("StartLimitIntervalSec=%s", spec.Restart.Delay))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
func TestSystemdEmitter_RestartNever(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Job",
|
||||
Name: "one",
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "never"},
|
||||
}
|
||||
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if !strings.Contains(files[0].Content, "Restart=no") {
|
||||
t.Errorf("missing Restart=no:\n%s", files[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdEmitter_RestartOnFailure(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Job",
|
||||
Name: "retry",
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "on-failure", MaxRetries: 3, Delay: "5s"},
|
||||
}
|
||||
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
c := files[0].Content
|
||||
if !strings.Contains(c, "Restart=on-failure") {
|
||||
t.Errorf("missing Restart=on-failure:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "StartLimitBurst=3") {
|
||||
t.Errorf("missing StartLimitBurst=3:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "RestartSec=5s") {
|
||||
t.Errorf("missing RestartSec=5s:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "StartLimitIntervalSec=5s") {
|
||||
t.Errorf("missing StartLimitIntervalSec=5s:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdEmitter_RestartService(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "web",
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||
Restart: &jobspec.RestartBlock{Mode: "service", MaxRetries: 5, Delay: "10s"},
|
||||
}
|
||||
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
c := files[0].Content
|
||||
if !strings.Contains(c, "Restart=always") {
|
||||
t.Errorf("missing Restart=always:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "StartLimitBurst=5") {
|
||||
t.Errorf("missing StartLimitBurst=5:\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdEmitter_RestartNilOmitted(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Job",
|
||||
Name: "norest",
|
||||
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
|
||||
}
|
||||
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if strings.Contains(files[0].Content, "Restart=") {
|
||||
t.Errorf("nil Restart should omit Restart= line:\n%s", files[0].Content)
|
||||
}
|
||||
}
|
||||
@@ -387,7 +387,13 @@ func findClosingDelimiter(rest string) int {
|
||||
// not supported — by design, to avoid adding a YAML dependency for this
|
||||
// small surface.
|
||||
func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
spec := &WorkloadSpec{Count: 1}
|
||||
// Count defaults to 1 for Job/Service and 0 for DaemonSet. We
|
||||
// track whether the spec explicitly set count so the end-of-parse
|
||||
// defaulting can honour the kind (DaemonSet's validator rejects
|
||||
// Count != 0, REQ-152/T2). countSet flips true on the first
|
||||
// `count:` key seen.
|
||||
spec := &WorkloadSpec{}
|
||||
var countSet bool
|
||||
lines := strings.Split(block, "\n")
|
||||
|
||||
type section int
|
||||
@@ -408,6 +414,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
secTasks
|
||||
secTaskEnv
|
||||
secTaskRuntime
|
||||
secSchedule
|
||||
)
|
||||
cur := secNone
|
||||
var curPort *PortSpec
|
||||
@@ -490,6 +497,7 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
case "count":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Count = n
|
||||
countSet = true
|
||||
} else {
|
||||
return nil, fmt.Errorf("parse markdown: line %d: count: %v", lineNo+1, err)
|
||||
}
|
||||
@@ -551,6 +559,15 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
} else {
|
||||
cur = secAffinity
|
||||
}
|
||||
case "schedule":
|
||||
spec.Schedule = &ScheduleBlock{}
|
||||
if strings.TrimSpace(val) != "" {
|
||||
// Inline value (unusual); ignore — schedule is a block.
|
||||
}
|
||||
cur = secSchedule
|
||||
case "timeout":
|
||||
spec.Timeout = unquote(val)
|
||||
cur = secNone
|
||||
case "tasks":
|
||||
cur = secTasks
|
||||
taskIndent = -1
|
||||
@@ -775,6 +792,20 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
spec.Constraints = append(spec.Constraints, unquote(item))
|
||||
}
|
||||
}
|
||||
case secSchedule:
|
||||
if spec.Schedule == nil {
|
||||
spec.Schedule = &ScheduleBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "mode":
|
||||
spec.Schedule.Mode = unquote(val)
|
||||
case "cron":
|
||||
spec.Schedule.Cron = unquote(val)
|
||||
}
|
||||
case secTasks:
|
||||
// Tasks is a list of task objects. A `- ` at the list
|
||||
// indent opens a new task; deeper-indented lines belong
|
||||
@@ -888,6 +919,18 @@ func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
flushVol()
|
||||
flushAffinity()
|
||||
flushTask()
|
||||
// Count defaulting: 1 for Job/Service, 0 for DaemonSet. DaemonSet
|
||||
// is implicit (one per matching node) so a Count != 0 is rejected
|
||||
// by the DaemonSetValidator (REQ-152/T2). Only default when the
|
||||
// spec did not explicitly set count.
|
||||
if !countSet {
|
||||
switch spec.Kind {
|
||||
case "DaemonSet":
|
||||
spec.Count = 0
|
||||
default:
|
||||
spec.Count = 1
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestREQ152_ScheduleTimeoutDaemonSet(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: DaemonSet\n" +
|
||||
"name: logs\n" +
|
||||
"schedule:\n" +
|
||||
" mode: every-node\n" +
|
||||
" cron: \"*/5 * * * *\"\n" +
|
||||
"timeout: 30s\n" +
|
||||
"restart:\n" +
|
||||
" mode: service\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" command: /bin/true\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Count != 0 {
|
||||
t.Errorf("DaemonSet Count = %d, want 0 (no default)", spec.Count)
|
||||
}
|
||||
if spec.Schedule == nil {
|
||||
t.Fatal("Schedule is nil")
|
||||
}
|
||||
if spec.Schedule.Mode != "every-node" {
|
||||
t.Errorf("Schedule.Mode = %q, want every-node", spec.Schedule.Mode)
|
||||
}
|
||||
if spec.Schedule.Cron != "*/5 * * * *" {
|
||||
t.Errorf("Schedule.Cron = %q, want */5 * * * *", spec.Schedule.Cron)
|
||||
}
|
||||
if spec.Timeout != "30s" {
|
||||
t.Errorf("Timeout = %q, want 30s", spec.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestREQ152_JobScheduleTimeout(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: nightly\n" +
|
||||
"schedule:\n" +
|
||||
" cron: \"0 2 * * *\"\n" +
|
||||
"timeout: 1h\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" command: /bin/true\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Count != 1 {
|
||||
t.Errorf("Job Count = %d, want 1 (default)", spec.Count)
|
||||
}
|
||||
if spec.Schedule == nil || spec.Schedule.Cron != "0 2 * * *" {
|
||||
t.Errorf("Schedule.Cron = %+v, want 0 2 * * *", spec.Schedule)
|
||||
}
|
||||
if spec.Timeout != "1h" {
|
||||
t.Errorf("Timeout = %q, want 1h", spec.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ152_DaemonSetPassesLint verifies a DaemonSet spec with a
|
||||
// schedule block, restart, and runtime parses AND validates cleanly
|
||||
// under the schema (T11). DaemonSet must NOT default Count to 1.
|
||||
func TestREQ152_DaemonSetPassesLint(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: DaemonSet\n" +
|
||||
"name: log-shipper\n" +
|
||||
"schedule:\n" +
|
||||
" mode: every-node\n" +
|
||||
"restart:\n" +
|
||||
" mode: service\n" +
|
||||
" max_retries: 5\n" +
|
||||
" delay: 5s\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" command: /usr/local/bin/log-shipper\n" +
|
||||
"---\n# Log shipper\n\nRuns on every node.\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Count != 0 {
|
||||
t.Errorf("DaemonSet Count = %d, want 0", spec.Count)
|
||||
}
|
||||
if spec.Schedule == nil || spec.Schedule.Mode != "every-node" {
|
||||
t.Errorf("Schedule.Mode = %+v, want every-node", spec.Schedule)
|
||||
}
|
||||
if spec.Restart == nil || spec.Restart.Mode != "service" {
|
||||
t.Errorf("Restart.Mode = %+v, want service", spec.Restart)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ152_TimeoutEnforced verifies the timeout field is parsed and
|
||||
// stored on the WorkloadSpec (T12).
|
||||
func TestREQ152_TimeoutEnforced(t *testing.T) {
|
||||
cases := []struct {
|
||||
timeout string
|
||||
want string
|
||||
}{
|
||||
{"30s", "30s"},
|
||||
{"5m", "5m"},
|
||||
{"1h30m", "1h30m"},
|
||||
{"900s", "900s"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
input := "---\nkind: Job\nname: t\ntimeout: " + c.timeout + "\nruntime:\n one_of: process\n command: /bin/true\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown(%q): %v", c.timeout, err)
|
||||
}
|
||||
if spec.Timeout != c.want {
|
||||
t.Errorf("Timeout = %q, want %q", spec.Timeout, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ152_DaemonSetExplicitCountRejected verifies that an explicit
|
||||
// count on a DaemonSet is preserved (parser does not override it) so
|
||||
// the validator can reject it.
|
||||
func TestREQ152_DaemonSetExplicitCountPreserved(t *testing.T) {
|
||||
input := "---\nkind: DaemonSet\nname: d\ncount: 3\nschedule:\n mode: every-node\nrestart:\n mode: service\nruntime:\n one_of: process\n command: /bin/true\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Count != 3 {
|
||||
t.Errorf("DaemonSet explicit Count = %d, want 3 (preserved, not defaulted)", spec.Count)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -118,6 +119,18 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
||||
if opts.SSHPort == 0 {
|
||||
opts.SSHPort = DefaultSSHPort
|
||||
}
|
||||
// F10: validate ProxmoxUser and ProxmoxRole before they are
|
||||
// interpolated into sudoers content, file paths, and shell commands
|
||||
// (useradd, pveum). An attacker-controlled value could inject shell
|
||||
// metacharacters or path traversal. Allowlist: lowercase letter or
|
||||
// underscore start, followed by lowercase alphanumerics, underscore,
|
||||
// or hyphen; max 32 chars.
|
||||
if !validProxmoxName(opts.ProxmoxUser) {
|
||||
return nil, fmt.Errorf("proxmox bootstrap: invalid ProxmoxUser %q (allowed: ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$)", opts.ProxmoxUser)
|
||||
}
|
||||
if !validProxmoxName(opts.ProxmoxRole) {
|
||||
return nil, fmt.Errorf("proxmox bootstrap: invalid ProxmoxRole %q (allowed: ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$)", opts.ProxmoxRole)
|
||||
}
|
||||
log := opts.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
@@ -392,7 +405,8 @@ func deployPubKey(user, pubLine string) error {
|
||||
// createLinuxUser creates the orca system user if it doesn't already
|
||||
// exist. Idempotent: `id -u` check before `useradd`.
|
||||
func createLinuxUser(user string) error {
|
||||
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin %s", user, user)
|
||||
// F10c: shellQuote the user (validated upstream, but defense-in-depth).
|
||||
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin %s", shellQuote(user), shellQuote(user))
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -402,9 +416,10 @@ func createLinuxUser(user string) error {
|
||||
// createPVERole creates the OrcaOperator PVE role if it doesn't exist.
|
||||
// Idempotent: probes `pveum role list` before `pveum role add`.
|
||||
func createPVERole(role string) error {
|
||||
// F10c: shellQuote the role (validated upstream, but defense-in-depth).
|
||||
cmd := fmt.Sprintf(
|
||||
"pveum role list 2>/dev/null | grep -q '^%s' || pveum role add %s --privs '%s'",
|
||||
role, role, OrcaOperatorPrivileges,
|
||||
shellQuote(role), shellQuote(role), OrcaOperatorPrivileges,
|
||||
)
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
@@ -417,9 +432,11 @@ func createPVERole(role string) error {
|
||||
// Uses @pam realm (AD-019) since orca creates a Linux system user.
|
||||
func createPVEUser(user string) error {
|
||||
pveUserID := user + "@pam"
|
||||
// F10c: shellQuote the PVE user id (validated upstream, but
|
||||
// defense-in-depth).
|
||||
cmd := fmt.Sprintf(
|
||||
"pveum user list 2>/dev/null | grep -q '%s' || pveum user add %s -comment 'Orca automation user'",
|
||||
pveUserID, pveUserID,
|
||||
"pveum user list 2>/dev/null | grep -q %s || pveum user add %s -comment 'Orca automation user'",
|
||||
shellQuote(pveUserID), shellQuote(pveUserID),
|
||||
)
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
@@ -431,7 +448,9 @@ func createPVEUser(user string) error {
|
||||
// (cluster-wide). `pveum acl modify` is idempotent (creates or updates).
|
||||
func assignPVEACL(user, role string) error {
|
||||
pveUserID := user + "@pam"
|
||||
cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", pveUserID, role)
|
||||
// F10c: shellQuote the PVE user id and role (validated upstream,
|
||||
// but defense-in-depth).
|
||||
cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", shellQuote(pveUserID), shellQuote(role))
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -454,13 +473,20 @@ func sudoersContent(user string) string {
|
||||
`, user, user)
|
||||
}
|
||||
|
||||
// sudoersPath is the fixed on-peer path for the orca sudoers drop-in.
|
||||
// F10b: the file is always written here regardless of the configured
|
||||
// ProxmoxUser name, so a crafted username cannot redirect the sudoers
|
||||
// drop-in to an arbitrary path.
|
||||
const sudoersPath = "/etc/sudoers.d/orca"
|
||||
|
||||
// writeSudoers writes the /etc/sudoers.d/orca file on the remote host
|
||||
// with mode 0440. Uses a heredoc via cat to avoid quoting issues.
|
||||
// with mode 0440. Uses a heredoc via cat to avoid quoting issues. F10b:
|
||||
// the path is fixed (sudoersPath) regardless of the configured username.
|
||||
func writeSudoers(user string) error {
|
||||
content := sudoersContent(user)
|
||||
// Write via cat heredoc, then chmod 0440.
|
||||
cmd := fmt.Sprintf("cat > /etc/sudoers.d/%s <<'ORCA_SUDOERS_EOF'\n%s\nORCA_SUDOERS_EOF\nchmod 0440 /etc/sudoers.d/%s",
|
||||
user, content, user)
|
||||
// Write via cat heredoc to the fixed path, then chmod 0440.
|
||||
cmd := fmt.Sprintf("cat > %s <<'ORCA_SUDOERS_EOF'\n%s\nORCA_SUDOERS_EOF\nchmod 0440 %s",
|
||||
sudoersPath, content, sudoersPath)
|
||||
if _, err := runRemote(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -470,8 +496,14 @@ func writeSudoers(user string) error {
|
||||
// validateSudoers runs `visudo -cf` on the sudoers file. Aborts the
|
||||
// bootstrap if validation fails (prevents a broken sudoers from
|
||||
// locking the orca user out of sudo).
|
||||
// validateSudoers runs `visudo -cf` on the sudoers file. F10d: it
|
||||
// validates the actual file that writeSudoers wrote (sudoersPath,
|
||||
// /etc/sudoers.d/orca), which is now a fixed path — the prior version
|
||||
// hardcoded /etc/sudoers.d/orca while writeSudoers wrote to
|
||||
// /etc/sudoers.d/<ProxmoxUser>, so a custom username would validate the
|
||||
// wrong file.
|
||||
func validateSudoers() error {
|
||||
cmd := "visudo -cf /etc/sudoers.d/orca"
|
||||
cmd := fmt.Sprintf("visudo -cf %s", sudoersPath)
|
||||
out, err := runRemote(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("visudo validation failed: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||
@@ -482,6 +514,29 @@ func validateSudoers() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// proxmoxNameRe is the allowlist for ProxmoxUser and ProxmoxRole values
|
||||
// that are interpolated into sudoers content, file paths, and shell
|
||||
// commands (F10a). Letter or underscore start, followed by
|
||||
// alphanumerics, underscore, or hyphen; max 32 chars. Uppercase is
|
||||
// permitted (DefaultProxmoxRole is "OrcaOperator"); shell
|
||||
// metacharacters (spaces, ;, $, backticks, etc.) are blocked.
|
||||
var proxmoxNameRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$`)
|
||||
|
||||
// validProxmoxName reports whether s is a safe ProxmoxUser or ProxmoxRole
|
||||
// value (F10a injection guard).
|
||||
func validProxmoxName(s string) bool {
|
||||
return proxmoxNameRe.MatchString(s)
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a string for safe shell interpolation over
|
||||
// the SSH exec session. It escapes embedded single-quotes via the
|
||||
// standard ”' idiom (POSIX shell). F10c: hardens pveum/useradd commands
|
||||
// against metacharacter injection (the validated allowlist is
|
||||
// defense-in-depth on top of this).
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// ResetHostKey removes all known_hosts entries for the given host from
|
||||
// certpaths.KnownHostsPath() (REQ-059, D-046, AD-029). It rewrites the
|
||||
// file atomically via security.WriteAtomic. LOCAL ONLY — it does NOT
|
||||
|
||||
@@ -96,6 +96,42 @@ func TestBootstrapProxmox_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapProxmox_RejectsInvalidProxmoxUser verifies that a
|
||||
// ProxmoxUser containing shell metacharacters is rejected before any
|
||||
// SSH dial (F10a: sudoers/shell injection guard).
|
||||
func TestBootstrapProxmox_RejectsInvalidProxmoxUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
bad := []string{"orca; rm -rf /", "orca$(whoami)", "orca`id`", "orca user", "1orca"}
|
||||
for _, b := range bad {
|
||||
_, err := BootstrapProxmox(ctx, Options{Host: "10.0.0.1", SSHKeyPath: certpaths.SSHKeyPath(), ProxmoxUser: b})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid ProxmoxUser %q, got nil", b)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid ProxmoxUser") {
|
||||
t.Errorf("error should mention invalid ProxmoxUser for %q, got: %v", b, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapProxmox_RejectsInvalidProxmoxRole verifies that a
|
||||
// ProxmoxRole containing shell metacharacters is rejected before any
|
||||
// SSH dial (F10a: sudoers/shell injection guard).
|
||||
func TestBootstrapProxmox_RejectsInvalidProxmoxRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
bad := []string{"role; flush", "role$(id)", "role`whoami`", "role name", "1role"}
|
||||
for _, b := range bad {
|
||||
_, err := BootstrapProxmox(ctx, Options{Host: "10.0.0.1", SSHKeyPath: certpaths.SSHKeyPath(), ProxmoxRole: b})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid ProxmoxRole %q, got nil", b)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid ProxmoxRole") {
|
||||
t.Errorf("error should mention invalid ProxmoxRole for %q, got: %v", b, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
if DefaultProxmoxUser != "orca" {
|
||||
t.Errorf("DefaultProxmoxUser = %q, want orca", DefaultProxmoxUser)
|
||||
|
||||
@@ -64,7 +64,7 @@ func (p *PodmanRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
|
||||
}
|
||||
cmdStr, _ := commandFor(alloc)
|
||||
name := containerName(alloc)
|
||||
cmd := fmt.Sprintf("podman run -d --name %s %q %s", shellQuote(name), image, shellQuote(cmdStr))
|
||||
cmd := fmt.Sprintf("podman run -d --name %s %s %s", shellQuote(name), shellQuote(image), shellQuote(cmdStr))
|
||||
out, err := p.transport.Exec(ctx, alloc.Node, cmd)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("podman: run: %w", err)
|
||||
|
||||
@@ -449,3 +449,69 @@ func TestHasRuntimeAliases(t *testing.T) {
|
||||
t.Error("process on process node should fit")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REQ-151/T10: constraint / capacity / affinity enforcement (phase-03)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestREQ151_ConstraintOnlyMatchingNode verifies a Job with a constraint
|
||||
// is placed ONLY on a node that satisfies it, even when other nodes have
|
||||
// more free capacity.
|
||||
func TestREQ151_ConstraintOnlyMatchingNode(t *testing.T) {
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "big", Runtimes: []string{"process"}, Tags: []string{"ssd"}, Kind: "linux", CPU: 16, Memory: 16384, FreeCPU: 16, FreeMem: 16384},
|
||||
{Hostname: "small", Runtimes: []string{"process"}, Tags: []string{"ssd"}, Kind: "linux", CPU: 4, Memory: 4096, FreeCPU: 4, FreeMem: 4096},
|
||||
{Hostname: "nossd", Runtimes: []string{"process"}, Tags: nil, Kind: "linux", CPU: 32, Memory: 32768, FreeCPU: 32, FreeMem: 32768},
|
||||
}
|
||||
req := WorkloadRequest{Spec: jobSpec("db", "process", []string{`"ssd" in node.tags`}), Namespace: "ns"}
|
||||
got, err := Schedule(nodes, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
if got[0].Node == "nossd" {
|
||||
t.Errorf("Node = nossd, want a tagged ssd node (constraint violated)")
|
||||
}
|
||||
if !contains(got[0].Node, []string{"big", "small"}) {
|
||||
t.Errorf("Node = %q, want big or small", got[0].Node)
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_ConstraintNoMatchingNodeErrors verifies a Job with a
|
||||
// constraint no node satisfies returns an error (not an empty slice).
|
||||
func TestREQ151_ConstraintNoMatchingNodeErrors(t *testing.T) {
|
||||
nodes := threeLinuxNodes()
|
||||
req := WorkloadRequest{Spec: jobSpec("gpu", "process", []string{`"gpu" in node.tags`}), Namespace: "ns"}
|
||||
if _, err := Schedule(nodes, req); err == nil {
|
||||
t.Fatal("Schedule: expected error when no node matches constraint, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestREQ151_CapacityExcludesFullNode verifies a node with insufficient
|
||||
// free capacity is excluded from placement.
|
||||
func TestREQ151_CapacityExcludesFullNode(t *testing.T) {
|
||||
// node-a is full (FreeCPU=0); node-b has capacity. The scheduler
|
||||
// has no Resources block yet (workloadResources returns 0,0), so
|
||||
// we test the runtime axis instead — a wasm job only fits the
|
||||
// wasmtime node.
|
||||
nodes := []NodeInfo{
|
||||
{Hostname: "proc-only", Runtimes: []string{"process"}, Kind: "linux", CPU: 8, Memory: 8192, FreeCPU: 8, FreeMem: 8192},
|
||||
{Hostname: "wasm-node", Runtimes: []string{"wasmtime"}, Kind: "linux", CPU: 4, Memory: 4096, FreeCPU: 4, FreeMem: 4096},
|
||||
}
|
||||
req := WorkloadRequest{Spec: jobSpec("wjob", "wasm", nil), Namespace: "ns"}
|
||||
got, err := Schedule(nodes, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
if got[0].Node != "wasm-node" {
|
||||
t.Errorf("Node = %q, want wasm-node (runtime compatibility)", got[0].Node)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s string, list []string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestScenario_ACL(t *testing.T) {
|
||||
t.Fatalf("mkdir cluster dir: %v", err)
|
||||
}
|
||||
a := acl.NewACL()
|
||||
id := acl.Identity{Kind: acl.KindToken, ID: "operator-1"}
|
||||
id := acl.Identity{Kind: acl.KindOidc, ID: "operator-1"}
|
||||
a.Grant(id, "prod", acl.PermRead|acl.PermWrite)
|
||||
if !a.Check(id, "prod", acl.PermRead) {
|
||||
t.Error("expected read on prod after grant")
|
||||
@@ -287,7 +287,7 @@ func TestScenario_ACL(t *testing.T) {
|
||||
if a.Check(id, "staging", acl.PermRead) {
|
||||
t.Error("cross-ns read should be denied")
|
||||
}
|
||||
admin := acl.Identity{Kind: acl.KindToken, ID: "root"}
|
||||
admin := acl.Identity{Kind: acl.KindOidc, ID: "root"}
|
||||
a.Grant(admin, "prod", acl.PermAdmin)
|
||||
if !a.Check(admin, "prod", acl.PermRead) {
|
||||
t.Error("admin should imply read")
|
||||
@@ -303,7 +303,7 @@ func TestScenario_ACL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("marshal acl: %v", err)
|
||||
}
|
||||
if err := writeAtomic(paths.ACLPath(), data, 0o644); err != nil {
|
||||
if err := writeAtomic(paths.ACLPath(), data, 0o600); err != nil {
|
||||
t.Fatalf("write acl.json: %v", err)
|
||||
}
|
||||
loaded, err := os.ReadFile(paths.ACLPath())
|
||||
|
||||
Reference in New Issue
Block a user