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");
}
}