crates/nornir-build-thing/src/output.rs
//! The unified **artifact-output seam** — one datatype spanning what each
//! [`ArtifactKind`](crate::variant::ArtifactKind) produces.
//!
//! A build-thing is a `(kind × shape × target)` point. Once built, each kind
//! hands its consumer a *different* output descriptor: a suite `Service` hands
//! skidbladnir a [`ServiceSpec`](crate::service::ServiceSpec); a `Container`
//! hands a deploy step an image ref; a `Wasm` demo hands a static host a pages
//! bundle; an `Exe`/`Lib` hands an installer a binary path. [`ArtifactOutput`]
//! is the ONE enum that names them all, so the multi-axis build has a single
//! output type with one dispatch — mirroring how [`ArtifactKind`] unifies the
//! *input* axis.
//!
//! # Reuse vs. seam (per the REUSE-FIRST law)
//!
//! * **`Service`** → [`ServiceSpec`](crate::service::ServiceSpec) — the existing
//! skidbladnir hand-off ([`service_spec`](crate::service::service_spec)). REUSED
//! verbatim.
//! * **`Container`** → an [`ImageRef`] built from the delegated
//! [`ContainerBuild`](crate::container::ContainerBuild)'s primary tag. REUSES the
//! existing container descriptor as the authoring surface; the post-build digest
//! form is [`BuiltImage`](crate::image_build::BuiltImage).
//! * **`Vm`** → an [`ImageRef`] built from the delegated
//! [`VmBuild`](crate::vm::VmBuild)'s kernel path. REUSES the existing vm
//! descriptor.
//! * **`Exe`/`Lib`** → a [`BinaryArtifact`] (the intended install path). The
//! post-build, content-addressed form is
//! [`Artifact`](crate::provenance::Artifact); this is the pre-build intent.
//! * **`Wasm`** → a [`PagesBundle`]. **SEAM:** the suite has no first-class
//! web-pages-bundle descriptor yet (the facett web-demo target is still
//! emergent), so this is a minimal stand-in. CONSUMER FOLLOW-UP: replace it with
//! facett's real bundle descriptor when that lands.
use std::path::Path;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use crate::service::{service_spec, ServiceSpec};
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
/// A dep-light **image reference** — a `Container` or `Vm` output's handle for a
/// deploy step. For a container it is the primary tag (`repo/name:version`); for a
/// vm it is the kernel/boot-image path. The rich authoring descriptor stays the
/// [`ContainerBuild`](crate::container::ContainerBuild) /
/// [`VmBuild`](crate::vm::VmBuild); this is the resolved reference a consumer pulls.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageRef {
/// The delegated descriptor's name.
pub name: String,
/// The reference to pull/boot (container primary tag, or vm kernel path).
pub reference: String,
}
/// A **binary artifact** intent for an `Exe`/`Lib` output — the install path a
/// packager copies the built binary to. The post-build, content-addressed form is
/// [`Artifact`](crate::provenance::Artifact) (path + bytes + sha256); this is the
/// pre-build intent, before the build produces hashes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryArtifact {
/// The intended absolute install path of the built binary.
pub path: String,
/// `true` for a `Lib` (a library artifact), `false` for an executable.
pub lib: bool,
}
/// A **web pages bundle** descriptor for a `Wasm` output — the facett web-demo
/// target. **SEAM:** no first-class pages-bundle descriptor exists in the suite
/// yet, so this minimal shape (module + wasm target + served mount path) stands in
/// until facett's real descriptor lands (consumer follow-up).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PagesBundle {
/// The cdylib crate / wasm module the bundle is built from.
pub module: String,
/// The wasm32 target triple the module was compiled for.
pub target: String,
/// The path a static host serves the bundle under (default `/`).
pub mount: String,
}
/// A produced **distribution package** — the one output in this enum that is a
/// SET of files rather than a single handle, and the reason it is its own shape
/// instead of a [`BinaryArtifact`] pointed at the wheel.
///
/// A wheel on its own is a file you have to trust the machine that made it. The
/// artifact a consumer can actually check is the wheel plus the sdist plus the
/// `SHA256SUMS` plus the provenance over the produced bytes, so the descriptor
/// names the DIRECTORY and the members that must be in it — see
/// [`PackageBuild::required_members`](crate::package::PackageBuild::required_members),
/// which is where `members` comes from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageSet {
/// The distribution name as an index knows it (`facet_wrapped_web`).
pub distribution: String,
/// The packaging format (`pip`).
pub format: String,
/// The repo-relative directory the produced set lands in.
pub out_dir: String,
/// The members that must be present, in declaration order — the labels of
/// [`PackageMember`](crate::package::PackageMember).
pub members: Vec<String>,
}
/// The unified output of a built artifact — ONE enum across all six kinds, so the
/// multi-axis build hands its consumer a single, serde-round-trippable datatype.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ArtifactOutput {
/// An `Exe`/`Lib` binary (the install-path intent).
Binary(BinaryArtifact),
/// A `Wasm` web pages bundle (SEAM — see [`PagesBundle`]).
Pages(PagesBundle),
/// A `Container` image reference.
Image(ImageRef),
/// A `Vm` boot-image reference.
Vm(ImageRef),
/// An `Iso` bootable-CD reference (the mastered `.iso` output path / handle).
Iso(ImageRef),
/// A `Service` skidbladnir hand-off spec (REUSED existing seam).
Service(ServiceSpec),
/// A `Kernel` built-Linux-kernel reference: `name` is the declared handle,
/// `reference` the pinned upstream release it was built from
/// (`linux-<version>`).
Kernel(ImageRef),
/// A `Package` produced file set — see [`PackageSet`]. The one output here
/// that is a set rather than a handle.
Package(PackageSet),
}
impl ArtifactOutput {
/// The [`ArtifactKind`] this output corresponds to.
pub fn kind(&self) -> ArtifactKind {
match self {
// A bare binary maps to Exe; a Lib is distinguished by `BinaryArtifact::lib`.
ArtifactOutput::Binary(b) => {
if b.lib {
ArtifactKind::Lib
} else {
ArtifactKind::Exe
}
}
ArtifactOutput::Pages(_) => ArtifactKind::Wasm,
ArtifactOutput::Image(_) => ArtifactKind::Container,
ArtifactOutput::Vm(_) => ArtifactKind::Vm,
ArtifactOutput::Iso(_) => ArtifactKind::Iso,
ArtifactOutput::Service(_) => ArtifactKind::Service,
ArtifactOutput::Kernel(_) => ArtifactKind::Kernel,
ArtifactOutput::Package(_) => ArtifactKind::Package,
}
}
/// **What to call this output in a sentence** — the descriptor handle for the
/// delegating kinds, the install path for a binary, the module for a pages
/// bundle. Read by [`Assembled::produced`](crate::assemble::Assembled::produced)
/// so a failure names the thing rather than its kind.
pub fn name(&self) -> &str {
match self {
ArtifactOutput::Binary(b) => &b.path,
ArtifactOutput::Pages(p) => &p.module,
ArtifactOutput::Image(r)
| ArtifactOutput::Vm(r)
| ArtifactOutput::Iso(r)
| ArtifactOutput::Kernel(r) => &r.name,
ArtifactOutput::Service(s) => &s.name,
// The DISTRIBUTION, not the out_dir: a failure reading "produced no
// pkg-facet-wrapped/dist" names a path, and a failure reading
// "produced no facet_wrapped_web" names the thing that was meant.
ArtifactOutput::Package(p) => &p.distribution,
}
}
}
/// Resolve the [`ArtifactOutput`] a declared variant produces, dispatching on its
/// [`kind`](BuildVariant::kind). `exec_dir` is where a built binary/service lands
/// (e.g. `/usr/local/bin`); `set` resolves a delegating `Container`/`Vm` node's
/// `container_ref`/`vm_ref` to its rich descriptor.
///
/// This is the *seam* — it produces the output descriptor, not the artifact. The
/// caller pairs it with the build the dispatcher ran.
pub fn output_of(
set: &BuildVariantSet,
v: &BuildVariant,
exec_dir: &Path,
) -> Result<ArtifactOutput> {
match v.kind {
// REUSE: the existing skidbladnir hand-off seam.
ArtifactKind::Service => Ok(ArtifactOutput::Service(service_spec(v, exec_dir))),
// Exe/Lib → a binary install-path intent.
ArtifactKind::Exe | ArtifactKind::Lib => {
let bin_name = v
.bin
.as_deref()
.or(v.package.as_deref())
.unwrap_or(&v.name);
Ok(ArtifactOutput::Binary(BinaryArtifact {
path: exec_dir.join(bin_name).to_string_lossy().into_owned(),
lib: v.kind == ArtifactKind::Lib,
}))
}
// Wasm → a pages bundle (SEAM). `validate_kind` guarantees the wasm target.
ArtifactKind::Wasm => Ok(ArtifactOutput::Pages(PagesBundle {
module: v.package.clone().unwrap_or_else(|| v.name.clone()),
target: v.target.clone().unwrap_or_default(),
mount: "/".to_string(),
})),
// Container/Vm delegating node → resolve its descriptor to a ref. REUSE the
// existing ContainerBuild/VmBuild descriptors.
ArtifactKind::Container => {
let name = v
.container_ref
.as_deref()
.ok_or_else(|| anyhow!("variant '{}': kind 'container' has no container_ref", v.name))?;
let c = set.require_container(name)?;
let reference = c
.primary_tag()
.map(|t| t.to_string())
.unwrap_or_else(|| c.name.clone());
Ok(ArtifactOutput::Image(ImageRef {
name: c.name.clone(),
reference,
}))
}
ArtifactKind::Vm => {
let name = v
.vm_ref
.as_deref()
.ok_or_else(|| anyhow!("variant '{}': kind 'vm' has no vm_ref", v.name))?;
let vm = set.require_vm(name)?;
Ok(ArtifactOutput::Vm(ImageRef {
name: vm.name.clone(),
reference: vm.kernel.clone(),
}))
}
// An ISO is declared directly as a `[[build.iso]]` table; a `kind = "iso"`
// variant carries the ISO handle in its own name (no delegating `iso_ref`
// field), so its output reference resolves to a declared ISO of the same
// name when present, else the variant name itself.
// An installer's output resolves to the DECLARED [[build.installer]]
// cell of the same name — mirroring the Iso rule (the handle rides in
// the variant name; there is no delegating installer_ref field).
ArtifactKind::Installer => {
let reference = set
.installers
.iter()
.find(|i| i.name == v.name)
.map(|i| i.volume_id.clone())
.unwrap_or_else(|| v.name.clone());
Ok(ArtifactOutput::Vm(ImageRef { name: v.name.clone(), reference }))
}
ArtifactKind::Iso => {
let reference = set
.iso(&v.name)
.map(|iso| iso.name.clone())
.unwrap_or_else(|| v.name.clone());
Ok(ArtifactOutput::Iso(ImageRef { name: v.name.clone(), reference }))
}
// A kernel variant carries its handle in its own name (the Iso rule); its
// output reference is the pinned upstream release the declared
// [[build.kernel]] of the same name compiles.
ArtifactKind::Kernel => {
let reference = set
.kernel(&v.name)
.map(|k| format!("linux-{}", k.version))
.unwrap_or_else(|| v.name.clone());
Ok(ArtifactOutput::Kernel(ImageRef { name: v.name.clone(), reference }))
}
// A package variant carries its handle in its own name (the Iso rule); its
// output is the SET the declared [[build.package]] of the same name
// produces. An UNDECLARED package is refused here rather than described:
// every other kind in this match can fall back to naming itself because a
// handle is all it owes, but a package's output IS the member set, and a
// descriptor that guessed at a directory and a distribution name would be
// a statement about a release nobody declared.
ArtifactKind::Package => {
let pkg = set.packages.iter().find(|p| p.name == v.name).ok_or_else(|| {
anyhow!(
"variant '{}': kind 'package' with no [[build.package]] of that \
name — the cell is where the produced set is declared, and \
without it there is nothing to say what a release contains",
v.name
)
})?;
Ok(ArtifactOutput::Package(PackageSet {
distribution: pkg.distribution.clone(),
format: pkg.format.as_str().to_string(),
out_dir: pkg.out_dir.clone(),
members: pkg
.required_members()
.into_iter()
.map(|m| m.as_str().to_string())
.collect(),
}))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::variant::{Profile, ServiceShape};
use std::collections::BTreeSet;
fn round_trip(o: &ArtifactOutput) -> ArtifactOutput {
let json = serde_json::to_string(o).expect("serialize output");
serde_json::from_str(&json).expect("re-parse output")
}
/// A `Service` variant resolves to a `ServiceSpec` output that round-trips and
/// carries the dedicated system-user identity.
#[test]
fn service_kind_outputs_service_spec() {
let set = BuildVariantSet::default();
let v = BuildVariant {
name: "nornir".to_string(),
member: Some("nornir".to_string()),
bin: Some("nornir".to_string()),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
embeds: BTreeSet::from(["holger".to_string()]),
profile: Profile::Release,
..Default::default()
};
let out = output_of(&set, &v, Path::new("/usr/local/bin")).expect("service output");
assert_eq!(out.kind(), ArtifactKind::Service);
match &out {
ArtifactOutput::Service(s) => {
assert_eq!(s.user.as_deref(), Some("nornir"));
assert_eq!(s.exec, "/usr/local/bin/nornir");
}
other => panic!("expected Service, got {other:?}"),
}
assert_eq!(round_trip(&out), out, "service output round-trips");
}
/// An `Exe` outputs a binary artifact intent; a `Lib` sets `lib = true`.
#[test]
fn exe_and_lib_output_binary_artifact() {
let set = BuildVariantSet::default();
let exe = BuildVariant {
name: "tool".to_string(),
bin: Some("mytool".to_string()),
..Default::default()
};
let out = output_of(&set, &exe, Path::new("/usr/bin")).expect("exe output");
assert_eq!(out.kind(), ArtifactKind::Exe);
match &out {
ArtifactOutput::Binary(b) => {
assert_eq!(b.path, "/usr/bin/mytool");
assert!(!b.lib);
}
other => panic!("expected Binary, got {other:?}"),
}
assert_eq!(round_trip(&out), out);
let lib = BuildVariant {
name: "corelib".to_string(),
package: Some("nornir-core".to_string()),
kind: ArtifactKind::Lib,
..Default::default()
};
let out = output_of(&set, &lib, Path::new("/usr/lib")).expect("lib output");
assert_eq!(out.kind(), ArtifactKind::Lib);
match &out {
ArtifactOutput::Binary(b) => assert!(b.lib, "lib flag set"),
other => panic!("expected Binary(lib), got {other:?}"),
}
assert_eq!(round_trip(&out), out);
}
/// A `Wasm` variant outputs a pages bundle (the SEAM) that round-trips.
#[test]
fn wasm_kind_outputs_pages_bundle() {
let set = BuildVariantSet::default();
let v = BuildVariant {
name: "skade-wasm".to_string(),
package: Some("skade-web".to_string()),
kind: ArtifactKind::Wasm,
target: Some("wasm32-unknown-unknown".to_string()),
..Default::default()
};
let out = output_of(&set, &v, Path::new("/srv")).expect("wasm output");
assert_eq!(out.kind(), ArtifactKind::Wasm);
match &out {
ArtifactOutput::Pages(p) => {
assert_eq!(p.module, "skade-web");
assert_eq!(p.target, "wasm32-unknown-unknown");
assert_eq!(p.mount, "/");
}
other => panic!("expected Pages, got {other:?}"),
}
assert_eq!(round_trip(&out), out);
}
/// A `Container` delegating node resolves the declared image's primary tag as
/// its image ref, which round-trips.
#[test]
fn container_kind_outputs_image_ref() {
let toml = r#"
[build]
[[build.variant]]
name = "image"
kind = "container"
container_ref = "tunnr-appliance"
[[build.container]]
name = "tunnr-appliance"
base = "debian:bookworm-slim"
entrypoint = ["/usr/local/bin/tunnr"]
tags = ["tunnr-appliance:0.1"]
"#;
let set = BuildVariantSet::from_toml_str(toml).expect("parse");
let v = set.get("image").expect("delegating node");
let out = output_of(&set, v, Path::new("/")).expect("container output");
assert_eq!(out.kind(), ArtifactKind::Container);
match &out {
ArtifactOutput::Image(r) => {
assert_eq!(r.name, "tunnr-appliance");
assert_eq!(r.reference, "tunnr-appliance:0.1");
}
other => panic!("expected Image, got {other:?}"),
}
assert_eq!(round_trip(&out), out);
}
/// A `Vm` delegating node resolves the declared vm's kernel path, round-trips.
#[test]
fn vm_kind_outputs_vm_ref() {
let toml = r#"
[build]
[[build.variant]]
name = "boot"
kind = "vm"
vm_ref = "holger-appliance"
[[build.vm]]
name = "holger-appliance"
kernel = "/boot/vmlinuz"
rootfs = "artifacts/rootfs.cpio.gz"
mem_mb = 512
cores = 2
"#;
let set = BuildVariantSet::from_toml_str(toml).expect("parse");
let v = set.get("boot").expect("delegating node");
let out = output_of(&set, v, Path::new("/")).expect("vm output");
assert_eq!(out.kind(), ArtifactKind::Vm);
match &out {
ArtifactOutput::Vm(r) => {
assert_eq!(r.name, "holger-appliance");
assert_eq!(r.reference, "/boot/vmlinuz");
}
other => panic!("expected Vm, got {other:?}"),
}
assert_eq!(round_trip(&out), out);
}
}