Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

.nornir/artifactkind-design.md

build-thing ArtifactKind multi-axis design (task #25)

Status: design, ready for a build agent to execute. Crate: edda/crates/nornir-build-thing. Goal: add a first-class ArtifactKind{Exe, Lib, Wasm, Container, Vm, Service} axis on BuildVariant, orthogonal to the existing shape (fat/thin) axis — so the today-separate cargo / OCI-image / VM / wasm / service build paths become one kind-tagged output datatype with one dispatch, one enumeration, and one validation gate.


1. What exists today (the seam we are unifying)

Four unrelated descriptor families, three of them living in three sibling collections on BuildVariantSet, plus a runtime-only kind tag and two "implicit" kinds with no first-class representation:

Output Descriptor Where declared Build path
cargo exe(s) BuildVariant [[build.variant]] provenance::build_variant (variant.rs:384) via BuildVariant::cargo_args (variant.rs:209)
OCI/appliance image ContainerBuild (container.rs:160) [[build.container]] (variant.rs:319) image_build::build_container
bootable VM VmBuild (vm.rs:114) [[build.vm]] (variant.rs:325) image_build::build_vm
wasm (none — just a BuildVariant whose target="wasm32-…") [[build.variant]] same cargo path, no coherence check
service (none — a BuildVariant + service::service_spec() bridge, service.rs:165) [[build.variant]] cargo path, then hand-off to skidbladnir

Two axes already coexist on BuildVariant and prove orthogonal axes are the established pattern:

There is ALSO a redundant runtime enum image_build::TargetKind{Container, Vm} (image_build.rs:69-87) that tags provenance records only — it is a strict subset of what we are introducing and gets replaced by ArtifactKind.

The core problem: "which build path runs" is currently decided by which collection the descriptor came out of (variants vs containers vs vms) and, for wasm/service, not decided at all — nothing distinguishes a wasm cargo build from an exe cargo build, and nothing enforces target=wasm32 for the former or a derivable ServiceSpec for the latter. ArtifactKind makes that decision a first-class, validated field.


2. The design in one paragraph

Add an ArtifactKind enum (the axis vocabulary, all six kinds) and a BuildVariant.kind: ArtifactKind field (default Exe). The cargo family (Exe, Lib, Wasm, Service) is fully described by the existing BuildVariant fields — kind just names what cargo_args() produces and turns on the right coherence checks. The appliance family (Container, Vm) keeps its rich dedicated tables (ContainerBuild / VmBuild) as the authoring surface; ArtifactKind unifies those at the dispatch / enumeration / provenance layer (replacing TargetKind), and a BuildVariant may optionally act as a thin delegating node (kind="container" + container="<name>") so a UI can present one flat kind-tagged build list. One validate() gate enforces kind×target / kind×service / kind×shape coherence; one dispatcher routes each kind to its builder.

The shape axis stays exactly where it is and stays orthogonal: shape answers "fat or thin service composition" and is meaningful for Service (and Container); kind answers "what artifact comes out". A build point is one (kind × shape × package × bin × features × profile × target) point.


3. The enum (new — add to variant.rs, modelled on ServiceShape)

Insert after ServiceShape's impl block (i.e. after variant.rs:128). String serde so it reads from a bare TOML kind = "service", identical style to Profile / ServiceShape:

/// The **artifact kind** a build variant produces — the suite's THIRD build
/// axis, ORTHOGONAL to `shape` (fat/thin) and to
/// `(package × bin × features × profile × target)`.
///
/// `Exe`/`Lib`/`Wasm`/`Service` are all produced by `cargo build`
/// (`BuildVariant::cargo_args`); `Container`/`Vm` delegate to the rich
/// `ContainerBuild` / `VmBuild` descriptors. Deserializes from a bare TOML
/// string (`kind = "wasm"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ArtifactKind {
    /// A native executable (default) — a plain binary/tool.
    Exe,
    /// A cargo library artifact (`--lib`); no `--bin`.
    Lib,
    /// A `wasm32-*` module (requires a wasm `target`).
    Wasm,
    /// An OCI / appliance image — delegates to a `ContainerBuild`.
    Container,
    /// A bootable VM / appliance — delegates to a `VmBuild`.
    Vm,
    /// A nordisk suite-member daemon: an executable that ALSO wraps as a
    /// skidbladnir `ServiceSpec` (dedicated system user + unit). Requires a
    /// `member`.
    Service,
}

impl Default for ArtifactKind {
    fn default() -> Self { ArtifactKind::Exe }
}

impl From<String> for ArtifactKind {
    fn from(s: String) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "lib" | "library" | "rlib" | "cdylib" | "staticlib" => ArtifactKind::Lib,
            "wasm" | "wasm32" | "web" => ArtifactKind::Wasm,
            "container" | "image" | "oci" | "docker" => ArtifactKind::Container,
            "vm" | "appliance" | "boot" => ArtifactKind::Vm,
            "service" | "daemon" | "unit" => ArtifactKind::Service,
            // "exe" | "bin" | "binary" | "" → the default.
            _ => ArtifactKind::Exe,
        }
    }
}

impl From<ArtifactKind> for String {
    fn from(k: ArtifactKind) -> Self { k.as_str().to_string() }
}

impl ArtifactKind {
    /// Stable label (matches the canonical string form).
    pub fn as_str(&self) -> &'static str {
        match self {
            ArtifactKind::Exe => "exe",
            ArtifactKind::Lib => "lib",
            ArtifactKind::Wasm => "wasm",
            ArtifactKind::Container => "container",
            ArtifactKind::Vm => "vm",
            ArtifactKind::Service => "service",
        }
    }
    /// True for the kinds produced by `cargo build` (Exe/Lib/Wasm/Service) — the
    /// dispatcher routes these through `build_variant`, the rest through the
    /// image/vm builders.
    pub fn is_cargo(&self) -> bool {
        matches!(self, ArtifactKind::Exe | ArtifactKind::Lib
                     | ArtifactKind::Wasm | ArtifactKind::Service)
    }
    /// True for kinds whose composition the fat/thin `shape` + `embeds`/`reaches`
    /// machinery applies to (Service, Container).
    pub fn is_service_shaped(&self) -> bool {
        matches!(self, ArtifactKind::Service | ArtifactKind::Container)
    }
}

4. The field on BuildVariant

In the BuildVariant struct (variant.rs:148-191) add — placed after shape (variant.rs:181) so the two axes sit together, but with a serde default so every existing TOML/JSON still parses:

    /// The **artifact kind** this variant produces (default [`ArtifactKind::Exe`]).
    /// Orthogonal to [`shape`](Self::shape).
    #[serde(default, skip_serializing_if = "is_exe_kind")]
    pub kind: ArtifactKind,
    /// For `kind = "container"`: the `[[build.container]]` this variant delegates
    /// to (a thin unifying node so a UI can list every kind in one build list).
    /// Ignored for other kinds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub container_ref: Option<String>,
    /// For `kind = "vm"`: the `[[build.vm]]` this variant delegates to. Ignored
    /// for other kinds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vm_ref: Option<String>,

Add the serde predicate next to is_fat_shape (variant.rs:197):

fn is_exe_kind(k: &ArtifactKind) -> bool { *k == ArtifactKind::Exe }

Notes:


5. Build dispatch per kind

5a. cargo_args becomes kind-aware (variant.rs:209-242)

Add, right after the --bin block (variant.rs:218) and before --features:

        match self.kind {
            // A lib variant builds the crate library, never a bin.
            ArtifactKind::Lib => a.push("--lib".to_string()),
            _ => {}
        }

Wasm needs no new arg here — the --target wasm32-… it requires is already emitted by the existing target block (variant.rs:237-240); §5c's validation guarantees the target is present and is a wasm32 triple. Exe/Service are plain cargo build -p … --bin … exactly as today.

5b. ContainerBuild / VmBuild report their kind

container.rs — add to impl ContainerBuild (near container.rs:199):

    /// This descriptor's artifact kind — always [`ArtifactKind::Container`].
    pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
        crate::variant::ArtifactKind::Container
    }

vm.rs — add to impl VmBuild (near vm.rs:173):

    /// This descriptor's artifact kind — always [`ArtifactKind::Vm`].
    pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
        crate::variant::ArtifactKind::Vm
    }

5c. Replace image_build::TargetKind with ArtifactKind

image_build::TargetKind (image_build.rs:69-87) is {Container, Vm} — a strict subset. Delete it and use ArtifactKind in TargetProvenance.kind (image_build.rs:159) and everywhere TargetKind appears (image_build.rs:191, :207, the build_container/build_vm bodies, the TargetKind re-export in lib.rs:66).

ArtifactKind's string serde already yields "container" / "vm", matching the old #[serde(rename_all = "snake_case")] output for those two variants, so existing sealed target-provenance JSON keeps deserializing. Keep the TargetKind name as a deprecated alias for one release if any external caller imports it: pub use variant::ArtifactKind as TargetKind;.

5d. Single dispatcher

Add to impl BuildVariantSet (after build, variant.rs:627). It resolves a handle to a kind, then routes:

    /// The artifact kind a declared handle produces — a variant's `kind`, or
    /// Container/Vm for a handle naming a declared image/vm.
    pub fn kind_of(&self, handle: &str) -> Option<ArtifactKind> {
        if let Some(v) = self.get(handle) { return Some(v.kind); }
        if self.container(handle).is_some() { return Some(ArtifactKind::Container); }
        if self.vm(handle).is_some() { return Some(ArtifactKind::Vm); }
        None
    }

    /// Build any declared artifact by handle, dispatching on its kind. Cargo
    /// kinds (Exe/Lib/Wasm/Service) run `build_variant`; Container/Vm run through
    /// the injected image/vm builder seams. Returns the kind that was built (the
    /// caller pairs it with the produced attestation).
    pub fn dispatch_kind(&self, handle: &str) -> Result<ArtifactKind> {
        self.kind_of(handle)
            .ok_or_else(|| anyhow!("no build artifact '{handle}'"))
    }

The actual build entrypoints already exist and stay the front door — the dispatcher only decides which to call:

Because the image/vm builders are seams (ImageBuilder, VmImageBuilder, image_build.rs:114/:132), a fully-generic build(handle) that also runs containers/vms would have to carry those seam objects; keep the thin kind_of / dispatch_kind resolvers in the dep-light core and let the host (nornir CLI / elm.rs) own the match kind { … } that calls the concrete builder with its injected seam. This preserves the crate's dep-light default (lib.rs:5-7).


6. Unified enumeration + surface

Add to impl BuildVariantSet a flat kind-tagged listing so a CLI / UI shows one build list instead of three:

    /// Every declared artifact as `(kind, handle)`, across variants + containers
    /// + vms — the unified multi-axis output list.
    pub fn artifacts(&self) -> Vec<(ArtifactKind, &str)> {
        let mut out: Vec<(ArtifactKind, &str)> =
            self.variants.iter().map(|v| (v.kind, v.name.as_str())).collect();
        out.extend(self.containers.iter().map(|c| (ArtifactKind::Container, c.name.as_str())));
        out.extend(self.vms.iter().map(|vm| (ArtifactKind::Vm, vm.name.as_str())));
        out
    }

surface.rs — add a kind: String field to VariantView (surface.rs:34-60) set from v.kind.as_str() in VariantView::of (surface.rs:63); the container / vm views already carry their kind implicitly, but adding an explicit kind to ContainerView / VmView lets the UI colour/group by kind uniformly. elm.rs's BuildThingMsg::Build (elm.rs:160) then dispatches via §5d's kind_of.


7. Validation rules (the coherence gate)

Extend BuildVariantSet::validate (variant.rs:398-442) with a new validate_kind(v) call, added as step 5 right after the validate_shape call (variant.rs:440). New method (mirrors validate_shape):

    /// Kind coherence: enforce that a variant's `kind` agrees with its target,
    /// service identity, bin, and composition fields.
    pub fn validate_kind(&self, v: &BuildVariant) -> Result<()> {
        match v.kind {
            // Wasm ⇒ a wasm32 target is mandatory.
            ArtifactKind::Wasm => {
                let t = v.target.as_deref().unwrap_or_default();
                if !t.starts_with("wasm32") {
                    return Err(anyhow!(
                        "variant '{}': kind 'wasm' requires a wasm32 `target` \
                         (e.g. wasm32-unknown-unknown), got '{}'",
                        v.name, if t.is_empty() { "<none>" } else { t }
                    ));
                }
            }
            // Service ⇒ must resolve a ServiceSpec identity: needs a `member`
            // (the dedicated system-user name skidbladnir installs it under).
            ArtifactKind::Service => {
                if v.member.as_deref().unwrap_or("").trim().is_empty() {
                    return Err(anyhow!(
                        "variant '{}': kind 'service' requires a `member` \
                         (the dedicated system-user / ServiceSpec identity)",
                        v.name
                    ));
                }
                // A service is exe-shaped, never a wasm module.
                if v.target.as_deref().map_or(false, |t| t.starts_with("wasm32")) {
                    return Err(anyhow!(
                        "variant '{}': a 'service' is a native daemon, not a wasm target",
                        v.name
                    ));
                }
            }
            // Lib ⇒ builds the crate library, so no `--bin` may be selected.
            ArtifactKind::Lib => {
                if v.bin.is_some() {
                    return Err(anyhow!(
                        "variant '{}': kind 'lib' builds the package library and \
                         may not select a `bin`",
                        v.name
                    ));
                }
            }
            // Delegating nodes must point at a declared descriptor.
            ArtifactKind::Container => {
                let name = v.container_ref.as_deref().ok_or_else(|| anyhow!(
                    "variant '{}': kind 'container' needs `container_ref = \"…\"`", v.name))?;
                self.require_container(name)?; // exists?
            }
            ArtifactKind::Vm => {
                let name = v.vm_ref.as_deref().ok_or_else(|| anyhow!(
                    "variant '{}': kind 'vm' needs `vm_ref = \"…\"`", v.name))?;
                self.require_vm(name)?;
            }
            ArtifactKind::Exe => {}
        }
        // Composition (embeds/reaches) is only meaningful for service-shaped
        // kinds. A non-service that embeds siblings is incoherent.
        if !v.embeds.is_empty() && !v.kind.is_service_shaped() {
            return Err(anyhow!(
                "variant '{}': `embeds` requires kind 'service' (or 'container'); \
                 kind '{}' produces no composed service",
                v.name, v.kind.as_str()
            ));
        }
        Ok(())
    }

Rule summary (the coherence matrix):

kind requires forbids shape/embeds/reaches
Exe — — shape ignored; reaches OK (thin gRPC-client tool), embeds rejected
Lib — bin no embeds
Wasm target=wasm32-* — no embeds
Service member wasm target full fat/thin machinery (validate_shape, variant.rs:455) applies
Container container_ref → declared — may embed (fat image)
Vm vm_ref → declared — —

validate_shape (variant.rs:455-513) is unchanged but is now scoped by this step: because embeds non-empty now implies kind ∈ {Service, Container}, the downstream-asymmetry rule (variant.rs:477-511) only ever fires for genuinely composed services — a tightening, not a behaviour change for existing valid data (every current embedding variant becomes kind="service", see §8).


8. TOML migration (kind = on existing files)

kind defaults to Exe, so every existing file keeps parsing untouched — migration is additive and per-variant. The rule: a variant with a member + shape (a suite daemon) becomes kind = "service"; a plain build stays Exe (omit kind). Concrete diffs:

nornir/.nornir/build-variants.toml

Both variants have member = "nornir" + shape ⇒ services.

holger/.nornir/build-variants.toml

dwarves/.nornir/build-variants.toml

skade/.nornir/build-variants.toml (+ any facett/web member)

Add the first wasm example to exercise the new coherence rule:

[[build.variant]]
name = "skade-wasm"
package = "skade-web"        # the cdylib crate
kind = "wasm"
target = "wasm32-unknown-unknown"
no_default_features = true
profile = "release"

Container / Vm tables

[[build.container]] / [[build.vm]] need no change — they report their kind via artifact_kind() (§5b) and appear in artifacts() (§6). Only add a delegating kind="container" / kind="vm" variant if a suite wants that image in the same flat variant list as its exes (rare; optional).


9. Exports + tests

lib.rs:

Tests to add (mirror the existing service_shape_parses_and_round_trips style, variant.rs:1106):

  1. artifact_kind_parses_and_round_trips — every spelling + canonical string + default Exe.
  2. kind_defaults_to_exe_and_round_trips — a [[build.variant]] with no kind parses as Exe; serialize omits the field (via is_exe_kind).
  3. wasm_kind_requires_wasm32_target — kind="wasm" with no/x86_64 target is rejected; with wasm32-unknown-unknown it validates and cargo_args contains --target wasm32-unknown-unknown.
  4. service_kind_requires_member — kind="service" without member rejected; with member set it validates and service_spec yields user=Some(member).
  5. lib_kind_forbids_bin_and_emits_lib_flag — kind="lib" + bin rejected; valid lib's cargo_args contains --lib, not --bin.
  6. embeds_requires_service_kind — a kind="exe" variant with non-empty embeds is rejected; the same embeds under kind="service" validates.
  7. container_delegating_node_resolves — kind="container" + container_ref="tunnr-appliance" validates only when that container is declared.
  8. Update nornir_own_build_variants_discovers (variant.rs:1122) to assert set.get("nornir").unwrap().kind == ArtifactKind::Service after the §8 edit.
  9. target_provenance round-trip test in image_build still green after the TargetKind → ArtifactKind swap (proves the serde "container"/"vm" compat).

Red-when-broken guarantee (testable-code law): tests 3–6 each assert a validate error string on the incoherent case and success on the coherent one, so a regression that drops a coherence rule turns them red.


10. Orthogonality proof (why this is a clean axis, not a merge)


11. Execution order for the build agent

  1. variant.rs: add ArtifactKind enum + is_exe_kind + kind/container_ref/vm_ref fields.
  2. variant.rs: cargo_args --lib for Lib; validate_kind + wire into validate as step 5.
  3. variant.rs: kind_of / dispatch_kind / artifacts on BuildVariantSet.
  4. container.rs / vm.rs: artifact_kind().
  5. image_build.rs: TargetKind → ArtifactKind (field + fns + serde-compat check).
  6. service.rs: gate service_spec usage to kind == Service at call sites (no signature change needed).
  7. surface.rs / elm.rs: kind field on views + kind dispatch in Build.
  8. lib.rs: exports.
  9. Add the §9 tests; cargo test -p nornir-build-thing.
  10. Apply the §8 TOML kind = edits across nornir / holger / dwarves / skade; run each repo's validate_all (the *_own_build_variants_discovers tests) green.