crates/dwarves-runa/src/pipeline.rs
//! The typed pipeline schema — layer 1 (**shape**) and layer 2 (**vocabulary**)
//! of the `runa` design.
//!
//! A pipeline is a strongly-typed Rust value authored in **RON**. RON serialises
//! Rust enums / tagged variants natively (`Trigger::Cron`, `Step::CargoBuild`)
//! with comments + trailing commas, which is why the forge uses it over TOML.
//! These types are the single vocabulary shared across dwarves (the runner),
//! `xtask`, and — later — the visual builder, so the schema never forks.
use serde::{Deserialize, Serialize};
/// A whole CI/CD pipeline — the root of a `*.runa.ron` file.
///
/// Parse one with [`Pipeline::from_ron_str`], which *also validates* it (DAG
/// cycle-check + unknown-`needs` check) so a malformed pipeline **errors at load,
/// not at run**.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Pipeline {
/// Human name of the pipeline (e.g. `"korp-ci"`).
pub name: String,
/// What causes the pipeline to run. Empty = never auto-triggered
/// (invoke-only). Defaulted so `triggers` may be omitted in RON.
#[serde(default)]
pub triggers: Vec<Trigger>,
/// WHERE the steps run, for every stage that does not say otherwise
/// ([`Stage::runs_on`]). Defaulted (and never written back when it is the
/// default), so every `*.runa.ron` from before it existed loads unchanged and
/// means [`RunsOn::Container`].
#[serde(default, skip_serializing_if = "RunsOn::is_default")]
pub runs_on: RunsOn,
/// The stage DAG. Ordering between stages is expressed by
/// [`Stage::needs`], not by position in this vec.
pub stages: Vec<Stage>,
}
impl Pipeline {
/// Where `stage`'s steps run: the stage's own [`Stage::runs_on`] when it set
/// one, else the pipeline's [`Pipeline::runs_on`].
#[must_use]
pub fn runs_on_for(&self, stage: &Stage) -> RunsOn {
stage.runs_on.unwrap_or(self.runs_on)
}
}
/// **Where a stage's steps run** — the `runs_on` setting of a [`Pipeline`]
/// (pipeline-wide) and of a [`Stage`] (its override).
///
/// The runner lowers every step that executes a program onto this: inside an
/// OCI container booted through draupnir (`draupnir::BootSpec::container`, the
/// default), or in a KVM guest booted through the same `BootSpec`. A value with
/// no backend yet is refused BY NAME at load ([`RunsOn::has_backend`]), never run
/// somewhere else in its place.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum RunsOn {
/// An OCI container booted through draupnir over podman — the default.
#[default]
Container,
/// A KVM guest booted through draupnir's `BootSpec` (tunnr).
Vm,
/// A Kubernetes pod. NO BACKEND YET: refused at load, by name.
Kubernetes,
}
impl RunsOn {
/// The default ([`RunsOn::Container`]) — not written back to RON.
#[must_use]
pub fn is_default(&self) -> bool {
*self == RunsOn::default()
}
/// Is there a runner backend for this value? `Kubernetes` has none yet.
#[must_use]
pub fn has_backend(self) -> bool {
!matches!(self, RunsOn::Kubernetes)
}
/// The RON spelling (`"Container"`, `"Vm"`, `"Kubernetes"`).
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
RunsOn::Container => "Container",
RunsOn::Vm => "Vm",
RunsOn::Kubernetes => "Kubernetes",
}
}
}
/// A stage's `runs_on` is spelled like the pipeline's (`runs_on: Vm`): the
/// `Option` is the field's presence, not a RON `Some(..)` the author must write.
mod bare_runs_on {
use super::RunsOn;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn ser<S: Serializer>(v: &Option<RunsOn>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(r) => serde::Serialize::serialize(r, s),
None => s.serialize_none(),
}
}
pub(super) fn de<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RunsOn>, D::Error> {
RunsOn::deserialize(d).map(Some)
}
}
impl std::fmt::Display for RunsOn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// What causes a [`Pipeline`] to run. All four variants are locked in v1 so the
/// type is stable even before every runner path is wired.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trigger {
/// A push to the named branch.
Push {
/// The branch that, when pushed, fires the pipeline (e.g. `"main"`).
branch: String,
},
/// A cron schedule (standard 5-field crontab, e.g. `"0 3 * * *"`).
Cron(String),
/// Manual / on-demand invocation only.
Manual,
/// An inbound webhook (forge- or SCM-delivered).
Webhook,
}
/// One stage of a pipeline — a named group of [`Step`]s that runs after all the
/// stages it [`needs`](Stage::needs) have completed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stage {
/// Stage name — must be unique within the pipeline and is the key other
/// stages reference in their [`needs`](Stage::needs).
pub name: String,
/// Names of stages that must complete before this one starts. Defines the
/// stage DAG edges (`need → this`). Defaulted so a root stage may omit it.
#[serde(default)]
pub needs: Vec<String>,
/// This stage's override of [`Pipeline::runs_on`]; `None` = the pipeline's.
/// Defaulted and skipped when unset, so old files load and re-save unchanged.
/// Written bare (`runs_on: Vm`), never `Some(Vm)`.
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "bare_runs_on::ser",
deserialize_with = "bare_runs_on::de"
)]
pub runs_on: Option<RunsOn>,
/// The steps to run in this stage.
pub steps: Vec<Step>,
}
/// A cargo build profile. Defaults to [`Profile::Debug`] (cargo's own default)
/// so `profile` may be omitted in RON.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Profile {
/// `cargo build` (debug).
#[default]
Debug,
/// `cargo build --release`.
Release,
/// A named custom cargo profile (`[profile.<name>]`).
Named(String),
}
/// The policy an [`Step::Sbom`] supply-chain gate enforces.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SbomPolicy {
/// Fail the pipeline on a policy violation (a hard gate).
#[default]
Block,
/// Record the violation but let the pipeline continue.
Warn,
}
/// Which linter a [`Step::Lint`] runs. Generalises the old Rust-only `Clippy`
/// step so `fmt` (and any future linter) share one vocabulary word.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum LintTool {
/// `cargo clippy` — the Rust lint gate (the historical default).
#[default]
Clippy,
/// `cargo fmt --check` — the formatting gate.
Fmt,
}
/// What a [`Step::Trace`] / [`Step::Profile`] / [`Step::LiveTrace`] runs: a
/// test binary, a bench, or a binary of the checkout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RunTarget {
/// `cargo test` (`--test <name>`; no name = the package's own tests).
#[default]
Test,
/// `cargo bench` (`--bench <name>`).
Bench,
/// `cargo run` (`--bin <name>`).
Bin,
}
/// Which repos of a workspace a [`Step::BuildWorkspace`] reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum WorkspaceScope {
/// Every member (or the ones `repos` names).
#[default]
All,
/// Only the members a change reaches (Mímir's affected surface).
Affected,
}
/// Which tests a test-running step ([`Step::Nextest`], [`Step::CargoTest`],
/// [`Step::Coverage`]) runs.
///
/// ```ron
/// Nextest(only: Affected)
/// CargoTest(only: Affected)
/// Coverage(only: Affected)
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TestScope {
/// Every test (the default — and what a weekly / nightly full run uses).
#[default]
All,
/// Only the tests whose reach in Mímir's code graph includes a function that
/// changed since the repo's previous green commit (`Test.Affected` on the
/// forge's nornir). Tests the graph does not know always run; when the graph
/// cannot be sure (a `Cargo.toml`, a macro, a cfg change, no green baseline, the
/// commit not read yet …) every test runs and the step says why.
Affected,
}
/// The family of security scan a [`Step::SecurityScan`] performs. This is the
/// *scanning* side of supply-chain (SAST / SCA / secret / container-image); the
/// SBOM *emission* side stays [`Step::Sbom`] (modgunn).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScanKind {
/// Static application security testing (code analysis).
Sast,
/// Dependency / software-composition analysis (vulnerable deps).
Deps,
/// Secret detection (leaked credentials in the tree/history).
Secret,
/// Built container-image vulnerability scan.
Container,
}
/// Where a [`Step::Deploy`] delivers. The general deploy verb;
/// [`Step::DeployPages`] remains the one narrow wasm/pages special case.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeployTarget {
/// Push/promote to an artifact or image registry.
Registry {
/// Registry name / URL (e.g. `"ghcr.io/nordisk"`).
name: String,
},
/// Deploy to a named environment (e.g. `"staging"`, `"production"`).
Env {
/// The environment name.
name: String,
},
/// Apply a Kubernetes manifest artifact to a cluster.
K8s {
/// Path to the manifest artifact to apply.
manifest: String,
},
}
/// The channel a [`Step::Notify`] fans out on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotifyChannel {
/// Post to a Slack channel / target.
Slack {
/// The Slack channel or target id.
target: String,
},
/// Send email to the given recipients.
Email {
/// Recipient addresses.
#[serde(default)]
to: Vec<String>,
},
/// POST to an arbitrary webhook URL.
Webhook {
/// The webhook endpoint.
url: String,
},
}
/// When a [`Step::Notify`] fires relative to the pipeline outcome.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum NotifyOn {
/// Only on success.
Success,
/// Only on failure.
#[default]
Failure,
/// On every completion regardless of outcome.
Always,
}
/// The IaC backend a [`Step::ProvisionTestEnv`] uses to stand up an ephemeral
/// test system (service-container test deps stay jera `Container` payloads on
/// the Test step — this is the *whole-stack* case).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvisionBackend {
/// A draupnir spec (KVM / OCI / bare-metal) for the repeatable full-system
/// autotest — the native nordisk provisioned test system.
Draupnir {
/// Path to the draupnir spec artifact.
spec: String,
},
/// A Terraform module artifact dwarves emits and applies (then destroys),
/// for cloud targets. No inline `terraform` shell — the module is a
/// content-addressed build artifact.
Terraform {
/// Path to the emitted Terraform module artifact.
module: String,
},
}
/// The v1 **step catalog** — layer 2, the readable vocabulary. Each variant wraps
/// one existing nordisk capability with typed params (like a type-checked GitHub
/// Actions `uses:`). Humans compose named steps, never raw shell; real branching
/// logic goes into a compiled [`Step::Xtask`].
///
/// # `deny_unknown_fields`, and why it is not tidiness (2026-08-27)
///
/// A field name this catalog does not know is now a LOAD ERROR. It used to be
/// silently discarded, and the moment `Step::Build` grew an option axis that turned
/// into the very defect the axis exists to prevent: an operator writes
///
/// ```text
/// Build(variant: "gunnar-appliance-iso", optz: ["wireguard=off"])
/// ```
///
/// and — before this attribute — got a green run, a mastered medium, and a
/// WireGuard client inside it, with nothing anywhere saying the toggle had been
/// thrown away. A customer choice dropped in silence is worse than a refusal, and
/// this is a *.runa.ron file in review, so a refusal costs one line of diff.
///
/// MEASURED: `a_misspelled_build_field_is_refused_at_load` failed against this
/// deserializer — RON accepted `optz:` and produced `Build { variant: "x", opts: [],
/// params: [] }`. Which also means the compatibility green next to it
/// (`bare_build_step_still_parses_without_opts_or_params`) was proving nothing at
/// the time: a deserializer that ignores every unknown field greens a bare form for
/// free. Both halves needed this.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Step {
/// `cargo build` with the given feature set, profile and optional target triple.
CargoBuild {
/// Cargo features to enable.
#[serde(default)]
features: Vec<String>,
/// The build profile.
#[serde(default)]
profile: Profile,
/// Optional `--target <triple>`; `None` = host target.
#[serde(default)]
target: Option<String>,
},
/// `cargo nextest run` — the fast test runner.
///
/// ```ron
/// Nextest()
/// Nextest(all_features: true)
/// Nextest(only: Affected) // only the tests a change reaches
/// ```
Nextest {
/// Run with `--all-features`.
#[serde(default)]
all_features: bool,
/// Every test (default), or only the ones a change reaches ([`TestScope`]).
#[serde(default)]
only: TestScope,
},
/// `cargo test` — the built-in test runner.
///
/// ```ron
/// CargoTest // = CargoTest(), every test
/// CargoTest(only: Affected) // only the tests a change reaches
/// ```
///
/// The bare `CargoTest` (a unit variant until 2026-10-17) still loads:
/// [`Pipeline::from_ron_str`] reads it as `CargoTest()`.
CargoTest {
/// Every test (default), or only the ones a change reaches ([`TestScope`]).
#[serde(default)]
only: TestScope,
},
/// **Run the repo's `nornir-bench` example** — the bench harness every
/// nordisk repo carries (`examples/`, `xtask/examples/`, a detached
/// `bench/`) — and file what it measured into the workspace's warehouse
/// (`bench_runs` / `bench_results`, and its own `test_outcomes`), keyed by the
/// checkout's repo and commit.
///
/// ```ron
/// Bench() // examples/nornir-bench.rs at the root
/// Bench(package: "xtask") // xtask/examples/nornir-bench.rs
/// Bench(dir: "bench") // a detached bench/ workspace
/// Bench(features: ["s3"])
/// ```
///
/// Every field is defaulted, so `Bench()` is the whole step for a repo whose
/// example sits at the root.
Bench {
/// `-p <package>` when the example is not the root package's (`"xtask"`).
#[serde(default)]
package: String,
/// A sub-directory of the checkout to run cargo in (`"bench"` for a
/// detached bench workspace). A NAME path below the checkout: absolute or
/// `..` is refused at the forge, never resolved.
#[serde(default)]
dir: String,
/// Cargo features for the bench example.
#[serde(default)]
features: Vec<String>,
},
/// **Source coverage of the repo's tests** (`cargo llvm-cov`, LLVM
/// source-based coverage), filed per function into the workspace's
/// warehouse (`coverage` / `coverage_fn`), each function keyed by its file
/// and line span so the code room's function joins it — keyed by the
/// checkout's repo and commit.
///
/// ```ron
/// Coverage()
/// Coverage(all_features: true)
/// Coverage(only: Affected) // instrumented build, only the tests a change
/// // reaches; merged with the previous run
/// ```
Coverage {
/// Run with `--all-features`.
#[serde(default)]
all_features: bool,
/// Every test (default), or only the ones a change reaches ([`TestScope`]):
/// the functions that did not change keep the previous run's coverage.
#[serde(default)]
only: TestScope,
},
/// **Trace every function call of a real run** (LLVM XRay): the target is
/// built with `-Z instrument-xray=always` (every function gets entry/exit
/// sleds, small ones too) and run with the XRay basic-mode runtime logging;
/// the log is read back into (thread, time, function, enter/exit) events, each
/// function mapped to the code room by its source location, and filed into
/// the workspace's warehouse (`fn_trace_events`, `fn_trace_runs`) — the 3D
/// code map replays it. Keyed by the checkout's repo and commit.
///
/// ```ron
/// Trace(target: Test, name: "code_room") // cargo test --test code_room
/// Trace(target: Test, name: "code_room", args: ["map_"]) // a filter after `--`
/// Trace(target: Bin, name: "dwarves", args: ["--help"])
/// Trace(target: Bench, name: "zip", package: "znippy")
/// ```
Trace {
#[serde(default)]
target: RunTarget,
/// The test / bench / bin NAME (`""` = the package's own unit tests for
/// `Test`; required for `Bench` and `Bin`).
#[serde(default)]
name: String,
/// Arguments to the traced program (after `--`), one argv slot each.
#[serde(default)]
args: Vec<String>,
/// `-p <package>` when the target is not the root package's.
#[serde(default)]
package: String,
/// Keep at most this many events (the earliest); `0` = the forge's
/// default (2 000 000).
#[serde(default)]
max_events: u64,
},
/// **Sample where a run spends its time** (`perf record -g`, no rebuild):
/// call stacks sampled on the CPU clock, folded per function into self and
/// total time and filed into `fn_profile` — the 3D code map's heat layer.
/// Needs `perf_event_open` for the run (see the forge's note on the step):
/// refused by name where the box does not allow it, never weakened.
///
/// ```ron
/// Profile(target: Test, name: "code_room")
/// Profile(target: Bin, name: "dwarves", args: ["serve", "--once"])
/// ```
Profile {
#[serde(default)]
target: RunTarget,
#[serde(default)]
name: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
package: String,
},
/// **Watch chosen functions of a running program live** (eBPF uprobes via
/// `bpftrace`): each entry / return of the named functions streams to the
/// warehouse in short chunks while it runs, and the 3D map shows them as they
/// come. OPT-IN and privileged: attaching uprobes needs `CAP_BPF` +
/// `CAP_PERFMON` (or root) on the box that runs it, so the forge runs it only
/// where its operator allowed it (`DWARVES_ALLOW_UPROBES=1`) and refuses it
/// by name everywhere else. Never in a container.
///
/// ```ron
/// LiveTrace(target: Bin, name: "dwarves", args: ["serve"], functions: ["dwarves::serve::*"], seconds: 60)
/// ```
LiveTrace {
#[serde(default)]
target: RunTarget,
#[serde(default)]
name: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
package: String,
/// Demangled function paths (a trailing `*` = every function below a
/// module); at most 64 probes.
functions: Vec<String>,
/// How long to watch (`0` = until the program exits, at most 600 s).
#[serde(default)]
seconds: u32,
},
/// Lint gate — generalises the former Rust-only `Clippy` step over a
/// [`LintTool`] so `fmt`/clippy/… share one vocabulary word.
Lint {
/// Which linter to run.
#[serde(default)]
tool: LintTool,
/// Treat warnings as errors (`-D warnings`).
#[serde(default)]
deny_warnings: bool,
},
/// Materialise a **build-thing** variant **by name**. `runa` does not
/// duplicate build-thing's package × bin × feature × profile × target
/// vocabulary — it references a declared variant and dwarves invokes
/// build-thing to build it.
///
/// # The CUSTOMER CHOICE rides here (2026-08-27)
///
/// A declared cell can carry an option axis (`[[build.iso.options]]`,
/// `[[build.installer.options]]`) and a parameter axis
/// (`[[build.iso.params]]`) — the two things that decide WHICH appliance a
/// medium is. Both the ⚒ pane and `dwarves buildthing --opt/--param` have
/// been able to express them; a `*.runa.ron` carried only the variant NAME,
/// so the CI door could not build a per-site medium at all. It could only
/// name the recipe and take its defaults.
///
/// Both fields are `#[serde(default)]`, so every shipped pipeline's bare
/// `Build(variant: "x")` parses byte-for-byte as before (asserted by
/// `bare_build_step_still_parses_without_opts_or_params`). They travel to
/// the forge as the same `--opt` / `--param` argv the two other doors
/// spell, and are judged by the same
/// `assemble_cell::resolve_cell_options` — there is no second resolver.
Build {
/// The build-thing variant name (e.g. `"holger-embedded"`).
variant: String,
/// **The OPT-OUT toggles**, `name=on|off` — verbatim as
/// `dwarves buildthing --opt` spells them (e.g. `"wireguard=off"`).
/// An undeclared name is refused BY NAME at the forge, never applied
/// to nothing.
#[serde(default)]
opts: Vec<String>,
/// **The per-site parameter values**, `name=value` — verbatim as
/// `dwarves buildthing --param` spells them (e.g.
/// `"admin_key=ssh-ed25519 AAAA…"`). A `@fixture` value resolves
/// against the cell's declared fixtures.
#[serde(default)]
params: Vec<String>,
},
/// **Read a whole workspace into its code room — the fast workspace build
/// (builder 1)**: every member compiled ONCE per unit by the planner
/// (one combined unit graph, one target dir, shared units built once),
/// its facts read by the Mímir driver while it compiles, per-unit state on
/// the run. Executed by dwarves as jera jobs into draupnir containers.
///
/// ```ron
/// BuildWorkspace(workspace: "nordisk")
/// BuildWorkspace(workspace: "nordisk", only: Affected)
/// BuildWorkspace(workspace: "nordisk", repos: ["skade", "nornir"])
/// ```
BuildWorkspace {
/// The registered workspace NAME (never a path).
workspace: String,
/// Every member, or only the ones a change reaches.
#[serde(default)]
only: WorkspaceScope,
/// Narrow to these member NAMES (empty = all of `only`).
#[serde(default)]
repos: Vec<String>,
},
/// Build a container image from the given container file (podman, no shell).
ContainerBuild {
/// Path to the container definition file.
file: String,
},
/// Boot the built artifact in a KVM/microVM as a smoke gate (tunnr).
VmBoot,
/// Supply-chain SBOM gate (modgunn).
Sbom {
/// How a violation is treated.
policy: SbomPolicy,
},
/// Package the holger executables for the given targets (holger).
HolgerPackage {
/// Target triples / package targets to produce.
#[serde(default)]
targets: Vec<String>,
},
/// Run the nornir release doctor (pre-publish gate matrix).
ReleaseDoctor,
/// Publish crates to the registry (nornir release).
PublishCrates,
/// Build + deploy the wasm/pages site (xtask). The one narrow pages case;
/// the general deploy verb is [`Step::Deploy`].
DeployPages,
/// Run a security scan of the given [`ScanKind`] (SAST / deps / secret /
/// container). The scanning side of supply-chain — SBOM emission is
/// [`Step::Sbom`].
SecurityScan {
/// Which scan family to run.
kind: ScanKind,
},
/// Push a built container image to a registry under the given tags — the
/// *push* split out of [`Step::ContainerBuild`] (mirrors
/// `docker/build-push-action`).
PushImage {
/// Target registry (e.g. `"ghcr.io/nordisk"`).
registry: String,
/// Tags to publish (e.g. `["latest", "1.2.3"]`).
#[serde(default)]
tags: Vec<String>,
},
/// The general deploy verb — deliver to a registry, environment or k8s
/// cluster ([`DeployTarget`]). Container-by-default via jera.
Deploy {
/// Where the deploy lands.
target: DeployTarget,
},
/// A manual approval gate — pauses the stage DAG until a human approves
/// (the protected-environment / `when: manual` / `input` step).
ApprovalGate {
/// Who may approve (usernames / teams). Empty = anyone authorised.
#[serde(default)]
approvers: Vec<String>,
/// Optional auto-expiry (a human duration such as `"24h"`, `"30m"`);
/// `None` = wait indefinitely.
#[serde(default)]
timeout: Option<String>,
},
/// Notify a [`NotifyChannel`] on the given outcome ([`NotifyOn`]).
Notify {
/// Where the notification goes.
channel: NotifyChannel,
/// When it fires relative to the pipeline outcome.
#[serde(default)]
on: NotifyOn,
},
/// Provision an ephemeral IaC test environment ([`ProvisionBackend`]) for
/// integration/e2e, optionally auto-torn-down. The whole-stack complement to
/// jera service-container test deps.
ProvisionTestEnv {
/// The IaC backend that stands up the env.
backend: ProvisionBackend,
/// Auto-destroy the env when the stage completes.
#[serde(default)]
teardown: bool,
},
/// The escape hatch: run a compiled, tested `xtask` subcommand — the typed,
/// unit-testable replacement for Groovy. All real branching/computation lives
/// here, not in the RON.
///
/// This is the step that makes a runa something a Rust developer WRITES BY
/// HAND beside their `xtask`: the verbs they already wrote and tested become
/// callable pipeline steps, with no new machinery and no shell.
///
/// ```ron
/// Xtask(cmd: "fmt-check")
/// Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp")
/// ```
///
/// `args` and `repo` are defaulted, so every `*.runa.ron` written before they
/// existed stays valid under `deny_unknown_fields`.
Xtask {
/// The `xtask` subcommand to invoke (e.g. `"verify-demo"`).
cmd: String,
/// Arguments AFTER the subcommand, verbatim and in order: `["--variant",
/// "korp-ui"]`. Each element is one argv slot — never a string the runner
/// splits, because a splitter is a shell in disguise and the whole
/// catalog exists to not have one (`Step` has no `Shell` variant, and
/// `spawn_process` never calls `sh -c`).
#[serde(default)]
args: Vec<String>,
/// Which checkout to run in: a constellation member NAME (`"korp"`),
/// resolved by the runner as a sibling of the forge's root. Empty = the
/// forge's own repo, which is what every pre-0.2.3 `Xtask` step meant.
///
/// A NAME and not a path, deliberately. A pipeline step that could name
/// an absolute path would be a door onto the filesystem wearing a
/// build's clothes; the runner refuses anything absolute, empty-segmented
/// or containing `..` BY NAME rather than resolving it.
#[serde(default)]
repo: String,
/// The cargo ALIAS the verb is reached through. Empty = `xtask`, which is what
/// every pre-0.2.4 step meant (`cargo xtask <cmd>`).
///
/// THE LAW gives xtask ONE dependency, the repo's recipe, so a repo whose
/// non-build verbs (publish, verify, deploy) needed more moved them to a
/// sibling bin behind its own alias — facett: `ops`, so `Xtask(cmd:
/// "publish-holger", alias: "ops")` runs `cargo ops publish-holger`. A NAME
/// only (`[a-z0-9_-]+`, see [`alias_is_a_name`]): anything else is refused at
/// LOAD by name, never split and never resolved as a path.
#[serde(default)]
alias: String,
},
// ── full-system-autotest catalog (the North-Star harness, `.nornir/
// full-system-autotest-design.md`) ─────────────────────────────────────
// The orchestrator's three NEW step kinds. Each lowers onto an ALREADY-BUILT
// engine — jera boot/ledger + draupnir OCI (`SpinFleetMember`), the per-app
// robotui/web scenarios (`RobotDriveMember`), and nornir-testmatrix
// `discover::Surface` + `coverage::GateReport` (`CoverageGate`) — never new
// machinery. The lowerings live in the `fleet` crate's harness (the edda-side
// reference dwarves' `runa_exec` mirrors).
/// **Fleet spin** (provision leg) — bring one fleet member up as an OCI
/// container through jera's boot ledger → draupnir `run_to_completion`, then
/// hold on a healthcheck barrier until the member is Ready (draupnir
/// `functional_status`). The fleet-of-one MVP's provision step; teardown is
/// RampGuard-style RAII (the fleet handle drops → the member is killed).
SpinFleetMember {
/// The member's stable name — the fleet key it's driven + gated under.
name: String,
/// OCI image reference to run (produced by a prior build-thing `Container`
/// variant / `Build` step, or a draupnir `build_image`).
image: String,
/// Container ports to publish (host == container) — the served surface the
/// web/robot leg drives against.
#[serde(default)]
ports: Vec<u16>,
/// Extra container environment as `(KEY, VALUE)` pairs.
#[serde(default)]
env: Vec<(String, String)>,
/// The member's declared **viz-tab surface** — the tabs the coverage gate
/// requires proven (each tab must be driven both fat AND thin).
#[serde(default)]
surface: Vec<String>,
},
/// **Robot-drive a fleet member** (drive leg) — run the member's egui
/// (co-located kittest, **fat** mode) + web (`nornir robot` WebDriver, **thin**
/// mode) scenarios against the spun container, streaming `TestResultRow`s to
/// the warehouse. Each scenario names the viz tab it drives; the egui leg is
/// co-located (the honest `cmd_json` gap, design §6.1), the web leg is genuinely
/// remote against the served port.
RobotDriveMember {
/// The fleet member (a prior [`SpinFleetMember`](Step::SpinFleetMember)
/// `name`) to drive.
member: String,
/// Co-located egui/kittest scenarios — each names the viz tab it drives
/// (covers `viz_tab:<tab>@fat`).
#[serde(default)]
egui_scenarios: Vec<String>,
/// Web/WebDriver scenarios run against the container's served port — each
/// names the viz tab it drives (covers `viz_tab:<tab>@thin`).
#[serde(default)]
web_scenarios: Vec<String>,
},
/// **A program in a SEALED container** (lane RELBUILD-RUNA, 2026-10-29): the
/// step's own argv in `image`, with nothing but what the step names. The forge runs it
/// with a read-only root filesystem, every capability dropped, `no-new-privileges`,
/// the image by digest and never pulled, no network unless `network` says so, and
/// only `mounts` and `tmpfs` (no `$HOME`, no shared cache, no checkout of the forge's).
/// The steps of a release build are these. Refused at load: an image not named by
/// digest, an empty argv, a mount that is not absolute on both sides or climbs (`..`),
/// and an env name that looks secret.
Sandbox {
/// The image, by digest (`…@sha256:<hex>`).
image: String,
/// The argv, verbatim (never a string the runner splits).
cmd: Vec<String>,
/// Environment as `(NAME, VALUE)` pairs.
#[serde(default)]
env: Vec<(String, String)>,
/// Bind mounts, host directory onto container path.
#[serde(default)]
mounts: Vec<Mount>,
/// tmpfs mounts.
#[serde(default)]
tmpfs: Vec<Tmpfs>,
/// Network on (`true`) or none (`false`, the default).
#[serde(default)]
network: bool,
/// The working directory inside the container (empty = the image's).
#[serde(default)]
workdir: String,
},
/// **A named action of the forge itself** (lane RELBUILD-RUNA): work the runner
/// does in-process between program steps, by name, from the actions it was given
/// (a release build checks out each pin, checks the vendored crates against the
/// locks and stages each packaged crate here). No program, no container. A name the
/// runner does not know fails the step by name; `action` must be a NAME.
Forge {
/// The action's name (`[A-Za-z0-9_.-]+`).
action: String,
/// Its arguments, verbatim.
#[serde(default)]
args: Vec<String>,
},
/// **Coverage gate** (proof leg) — the `Gap == ∅` verdict over the fleet's
/// driven surface: nornir-testmatrix `discover::Surface − Covered − Allow`, the
/// HARD zero (`coverage::GateReport`). RED — naming the exact untested tab —
/// when any declared surface node went undriven this run.
CoverageGate {
/// The workspace label the gate is scoped to (e.g. `"nornir"`).
workspace: String,
/// Surface keys (`kind:id@mode`) explicitly excused from the gate
/// (recorded on the report, never silently dropped).
#[serde(default)]
allow: Vec<String>,
},
}
/// A bind mount of a [`Step::Sandbox`]: `host` (absolute) onto `path` (absolute) in
/// the container, read-only when `read_only`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mount {
/// The host directory (absolute, no `..`).
pub host: String,
/// Where it appears in the container (absolute, no `..`).
pub path: String,
/// Read-only (default: writable).
#[serde(default)]
pub read_only: bool,
}
/// A tmpfs of a [`Step::Sandbox`]: `path` in the container, at most `size_mb` MiB.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tmpfs {
/// Where it is mounted (absolute).
pub path: String,
/// Its size cap in MiB.
pub size_mb: u32,
}
/// Is `p` an absolute path with no `..`, `.` or empty segment, other than `/` itself?
#[must_use]
pub fn is_plain_absolute(p: &str) -> bool {
p.len() > 1 && p.starts_with('/') && p[1..].split('/').all(|s| !s.is_empty() && s != "." && s != "..")
}
/// Is `image` named by digest (`<name>@sha256:<64 hex>`)?
#[must_use]
pub fn image_by_digest(image: &str) -> bool {
image.split_once("@sha256:").is_some_and(|(n, h)| !n.is_empty() && h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Does an environment NAME look like it carries a secret (`TOKEN`, `SECRET`, `PASSWORD`,
/// `PASSWD`, `CREDENTIAL`, `PRIVATE`, or a `CARGO_REGISTRY*`, `SSH_*` or `AWS_*` name)?
#[must_use]
pub fn env_looks_secret(name: &str) -> bool {
let n = name.to_ascii_uppercase();
["TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "PRIVATE"].iter().any(|w| n.contains(w))
|| n.starts_with("CARGO_REGISTRY")
|| n.starts_with("SSH_")
|| n.starts_with("AWS_")
}
/// **The cargo alias an [`Step::Xtask`] step runs through** — `xtask` when the step
/// names none (the pre-0.2.4 meaning), else the step's own alias.
#[must_use]
pub fn xtask_alias(alias: &str) -> &str {
let a = alias.trim();
if a.is_empty() {
"xtask"
} else {
a
}
}
/// Is `alias` a cargo alias NAME (`[a-z0-9_-]+`, or empty = `xtask`)? A path, a
/// space (a second argv slot in disguise) or any other character is not.
#[must_use]
pub fn alias_is_a_name(alias: &str) -> bool {
alias.is_empty() || alias.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
/// Is `name` a workspace / member NAME (`[A-Za-z0-9_.-]+`, no `..`)? A path,
/// a space or any other character is not.
#[must_use]
pub fn is_a_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') && !name.contains("..")
}