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

nordisk/edda

crates/nornir-build-thing/src/service.rs

//! Build-variant → **installable service** seam.
//!
//! A [`BuildVariant`](crate::variant::BuildVariant) says *how to build* a suite
//! member's artifact. skidbladnir's `ServiceSpec` says *how to install & run* it
//! as a systemd/OpenRC/… unit under a dedicated system user. This module is the
//! ONE bridge between them: [`service_spec`] maps a built variant to a
//! [`ServiceSpec`] — the binary path, a per-member dedicated system user, the
//! service name, and the fat/thin agent mode — so a built artifact can be wrapped
//! as an installable unit with zero hand-rolled unit text.
//!
//! # Dep-light by design (the hand-off shape)
//!
//! `nornir-build-thing` is deliberately dependency-light so a leaf repo's `xtask`
//! can pull it cheaply (no iceberg / arrow / eframe — and no `tar`/`zstd` airgap
//! stack either). So the DEFAULT build defines a lean, `serde`-faithful
//! [`ServiceSpec`] POD here — the *hand-off shape*, NOT a parallel installer:
//! build-thing never renders a unit, it only hands skidbladnir a spec to render.
//!
//! Enabling the optional **`skidbladnir`** feature path-deps the real crate and
//! lights up [`ServiceSpec::to_skidbladnir`], which reconstructs the authoritative
//! `skidbladnir::ServiceSpec` through *its own* `ServiceSpec::new(..).user(..)`
//! builder — so the real install tree consumes the seam with no twinned render
//! logic. The POD's field names/shape mirror skidbladnir's on purpose to keep that
//! bridge a straight field-for-field map.
//!
//! WIRING TODO: once `nornir-build-thing` is comfortable taking the (lean) airgap
//! dep by default, make `skidbladnir` non-optional and have [`service_spec`]
//! return the real type directly, deleting the POD. Tracked in the suite-build
//! matrix workstream.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::variant::{BuildVariant, ServiceShape};

/// Install scope — a system service (dedicated user, `/etc/systemd/system`) vs a
/// per-user service. Mirrors `skidbladnir::Scope`; `System` is the suite default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Scope {
    /// A root-owned unit running as a dedicated system user. Default.
    #[default]
    System,
    /// A `--user` scope unit running as the caller.
    User,
}

/// Fat (holds state / embeds siblings) vs thin (routes to remote owners). The
/// suite-wide split, mirrors `skidbladnir::AgentMode`; carried so the wrapped unit
/// inherits the same fat/thin classification the [`ServiceShape`] declared.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AgentMode {
    /// The full server process: embeds siblings / holds state. Default.
    #[default]
    Fat,
    /// A client that reaches remote owners over gRPC.
    Thin,
}

impl From<ServiceShape> for AgentMode {
    /// A fat/mixed variant is a [`AgentMode::Fat`] node; a thin/standalone one is
    /// [`AgentMode::Thin`]. (Mixed still holds embedded state, so it maps to Fat.)
    fn from(s: ServiceShape) -> Self {
        match s {
            ServiceShape::FatService | ServiceShape::Mixed => AgentMode::Fat,
            // A 'not-applicable' linkage embeds nothing (validate_shape enforces it),
            // so it holds no state of its own — the thin node, same as standalone.
            ServiceShape::ThinStandalone | ServiceShape::NotApplicable => AgentMode::Thin,
        }
    }
}

/// The **dep-light hand-off shape** of an installable service — the subset of
/// skidbladnir's `ServiceSpec` the build→install seam populates. Field names and
/// shape mirror `skidbladnir::ServiceSpec` so [`ServiceSpec::to_skidbladnir`]
/// (feature `skidbladnir`) is a straight field-for-field bridge, not a re-model.
///
/// Not an installer: it carries no render logic. skidbladnir renders it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceSpec {
    /// Service name without any suffix (e.g. `"nornir"`).
    pub name: String,
    /// Human description (unit `Description=`).
    pub description: String,
    /// Absolute path of the installed binary to exec.
    pub exec: String,
    /// Args appended to `exec`.
    pub args: Vec<String>,
    /// Run-as dedicated system user. `None` ⇒ run as the caller.
    pub user: Option<String>,
    /// Run-as group. `None` ⇒ the renderer defaults it to `user`.
    pub group: Option<String>,
    /// Install scope — system service vs per-user service.
    pub scope: Scope,
    /// Environment, rendered to the backend's env file.
    pub env: Vec<(String, String)>,
    /// Fat (embeds siblings) vs thin (reaches remote owners).
    pub agent: AgentMode,
}

impl ServiceSpec {
    /// A spec with sane defaults (system scope, fat, no args) — mirrors
    /// `skidbladnir::ServiceSpec::new`.
    pub fn new(
        name: impl Into<String>,
        description: impl Into<String>,
        exec: impl Into<String>,
    ) -> Self {
        ServiceSpec {
            name: name.into(),
            description: description.into(),
            exec: exec.into(),
            args: Vec::new(),
            user: None,
            group: None,
            scope: Scope::System,
            env: Vec::new(),
            agent: AgentMode::Fat,
        }
    }

    /// The full command line (`exec` + space-joined `args`).
    pub fn command_line(&self) -> String {
        if self.args.is_empty() {
            self.exec.clone()
        } else {
            format!("{} {}", self.exec, self.args.join(" "))
        }
    }

    /// Bridge this hand-off spec to the authoritative `skidbladnir::ServiceSpec`,
    /// built through skidbladnir's OWN constructor + builder (no twinned render
    /// logic). Available under the optional `skidbladnir` feature.
    #[cfg(feature = "skidbladnir")]
    pub fn to_skidbladnir(&self) -> skidbladnir::service::ServiceSpec {
        use skidbladnir::service::{AgentMode as SkAgent, Scope as SkScope, ServiceSpec as SkSpec};
        let mut s = SkSpec::new(self.name.clone(), self.description.clone(), self.exec.clone());
        s.args = self.args.clone();
        s.user = self.user.clone();
        s.group = self.group.clone();
        s.scope = match self.scope {
            Scope::System => SkScope::System,
            Scope::User => SkScope::User,
        };
        s.env = self.env.clone();
        s.agent = match self.agent {
            AgentMode::Fat => SkAgent::Fat,
            AgentMode::Thin => SkAgent::Thin,
        };
        s
    }
}

/// Map a built [`BuildVariant`] to an installable [`ServiceSpec`].
///
/// * **name / user** = the variant's `member` (the dedicated system user a suite
///   member runs as — the reused skidbladnir lifecycle). Falls back to `bin`,
///   then `package`, then the variant `name` when no member is declared.
/// * **exec** = `exec_dir` joined with the built binary file name (`bin`, else the
///   member/package/variant name) — the absolute path skidbladnir execs.
/// * **agent** = fat/thin, derived from the variant's [`ServiceShape`].
/// * **description** = a one-line summary naming the shape and any embeds.
///
/// `exec_dir` is where the artifact lands after install (e.g. `/usr/local/bin` or
/// a cargo `target/release`); the seam does not build — it wraps an artifact that
/// a prior `build_variant` produced.
pub fn service_spec(v: &BuildVariant, exec_dir: &Path) -> ServiceSpec {
    // Service identity = the suite member; degrade gracefully for a plain variant.
    let ident = v
        .member
        .as_deref()
        .or(v.bin.as_deref())
        .or(v.package.as_deref())
        .unwrap_or(&v.name)
        .to_string();
    // The executable file name is the bin if named, else the member identity.
    let bin_name = v.bin.as_deref().unwrap_or(&ident);
    let exec = exec_dir.join(bin_name).to_string_lossy().into_owned();

    let description = match v.shape {
        ServiceShape::FatService => {
            if v.embeds.is_empty() {
                format!("{ident} nordisk suite service (fat, self-contained)")
            } else {
                format!(
                    "{ident} nordisk suite service (fat: embeds {})",
                    v.embeds.iter().cloned().collect::<Vec<_>>().join(", ")
                )
            }
        }
        ServiceShape::ThinStandalone => {
            format!("{ident} nordisk suite service (thin/standalone, reaches remote owners)")
        }
        ServiceShape::Mixed => format!(
            "{ident} nordisk suite service (mixed: embeds {}, reaches {})",
            v.embeds.iter().cloned().collect::<Vec<_>>().join(", "),
            v.reaches.iter().cloned().collect::<Vec<_>>().join(", "),
        ),
        ServiceShape::NotApplicable => {
            format!("{ident} nordisk artifact (fat/thin linkage does not apply)")
        }
    };

    let mut spec = ServiceSpec::new(ident.clone(), description, exec);
    // Each member runs as its OWN dedicated system user, from the same skidbladnir
    // lifecycle code (the constellation law).
    spec.user = Some(ident);
    spec.scope = Scope::System;
    spec.agent = AgentMode::from(v.shape);
    spec
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::variant::Profile;
    use std::collections::BTreeSet;
    use std::path::Path;

    #[test]
    fn fat_variant_maps_to_service_spec_with_user_and_exec() {
        let fat = BuildVariant {
            name: "nornir".to_string(),
            package: Some("nornir-cli".to_string()),
            bin: Some("nornir-server".to_string()),
            member: Some("nornir".to_string()),
            shape: ServiceShape::FatService,
            embeds: BTreeSet::from(["holger".to_string(), "skade".to_string()]),
            reaches: BTreeSet::from(["dwarves".to_string()]),
            profile: Profile::Release,
            ..Default::default()
        };
        let spec = service_spec(&fat, Path::new("/usr/local/bin"));
        // Service name + dedicated system user = the member.
        assert_eq!(spec.name, "nornir");
        assert_eq!(spec.user.as_deref(), Some("nornir"));
        // Exec = install dir joined with the built bin.
        assert_eq!(spec.exec, "/usr/local/bin/nornir-server");
        // Fat shape → a Fat agent node.
        assert_eq!(spec.agent, AgentMode::Fat);
        assert_eq!(spec.scope, Scope::System);
        // Description surfaces the embeds.
        assert!(spec.description.contains("embeds"), "{}", spec.description);
        assert!(spec.description.contains("holger"), "{}", spec.description);
        assert_eq!(spec.command_line(), "/usr/local/bin/nornir-server");
    }

    #[test]
    fn thin_variant_maps_to_thin_agent() {
        let thin = BuildVariant {
            name: "nornir-thin".to_string(),
            member: Some("nornir".to_string()),
            shape: ServiceShape::ThinStandalone,
            reaches: BTreeSet::from(["holger".to_string(), "skade".to_string()]),
            profile: Profile::Release,
            ..Default::default()
        };
        let spec = service_spec(&thin, Path::new("/opt/nornir/bin"));
        assert_eq!(spec.name, "nornir");
        assert_eq!(spec.user.as_deref(), Some("nornir"));
        // No `bin` declared → exec file name falls back to the member identity.
        assert_eq!(spec.exec, "/opt/nornir/bin/nornir");
        assert_eq!(spec.agent, AgentMode::Thin);
        assert!(spec.description.contains("thin"), "{}", spec.description);
    }

    /// The REUSE bridge: the hand-off POD reconstructs the authoritative
    /// `skidbladnir::ServiceSpec` field-for-field through skidbladnir's own builder.
    #[cfg(feature = "skidbladnir")]
    #[test]
    fn bridges_to_the_real_skidbladnir_spec() {
        let thin = BuildVariant {
            name: "nornir-thin".to_string(),
            member: Some("nornir".to_string()),
            bin: Some("nornir".to_string()),
            shape: ServiceShape::ThinStandalone,
            ..Default::default()
        };
        let pod = service_spec(&thin, Path::new("/usr/local/bin"));
        let sk = pod.to_skidbladnir();
        assert_eq!(sk.name, "nornir");
        assert_eq!(sk.exec, "/usr/local/bin/nornir");
        assert_eq!(sk.user.as_deref(), Some("nornir"));
        assert_eq!(sk.agent, skidbladnir::service::AgentMode::Thin);
        // The real spec can render a systemd unit — proving it IS the install type.
        assert!(sk.render_systemd_unit().contains("nornir"));
    }

    #[test]
    fn plain_variant_without_member_degrades_gracefully() {
        // A non-suite cargo variant (no member) still yields a usable spec.
        let plain = BuildVariant {
            name: "tool".to_string(),
            bin: Some("mytool".to_string()),
            ..Default::default()
        };
        let spec = service_spec(&plain, Path::new("/usr/bin"));
        assert_eq!(spec.name, "mytool"); // falls back to bin
        assert_eq!(spec.exec, "/usr/bin/mytool");
    }
}