use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tatara_nix::derivation::{
BridgeTarget, BuilderPhase, BuilderPhases, Derivation, Outputs, Source,
};
use crate::activation::ActivationScript;
use crate::config::{KernelSpec, SystemConfig};
#[derive(Debug, Error)]
pub enum SystemClosureError {
#[error("package `{0}` not found in any configured PackageSet")]
MissingPackage(String),
#[error("kernel spec `{0}` references a package not in any PackageSet")]
MissingKernel(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemClosure {
pub hostname: String,
pub system: String,
pub kernel: Derivation,
pub etc: Derivation,
pub services: BTreeMap<String, Derivation>,
pub activation: Derivation,
pub profile: Derivation,
}
impl SystemClosure {
pub fn from_config<P: tatara_pkgs::PackageSet + ?Sized>(
cfg: &SystemConfig,
pkgs: &P,
) -> Result<Self, SystemClosureError> {
let kernel = match &cfg.kernel {
KernelSpec::Bridge { attr_path } => bridge_derivation(
format!("linux-kernel-{}", sanitize(&cfg.hostname)),
BridgeTarget::nixpkgs(attr_path.clone()),
),
KernelSpec::Package { name } => pkgs
.get(name)
.ok()
.flatten()
.ok_or_else(|| SystemClosureError::MissingKernel(name.clone()))?,
KernelSpec::Custom { derivation } => derivation.clone(),
};
let mut etc_script = String::from("set -eu\nmkdir -p $out\n");
etc_script.push_str(&format!(
"echo '{}' > $out/hostname\n",
shell_escape(&cfg.hostname)
));
etc_script.push_str(&format!(
"cat > $out/os-release <<'EOF'\nNAME=\"tatara-os\"\nID=tatara\nPRETTY_NAME=\"tatara-os {}\"\nEOF\n",
cfg.hostname
));
for f in &cfg.environment.etc_files {
etc_script.push_str(&format!(
"mkdir -p $(dirname $out/{p}) && cat > $out/{p} <<'TATARA_EOF'\n{c}TATARA_EOF\n",
p = f.path,
c = f.content,
));
}
if !cfg.users.is_empty() {
etc_script.push_str(": > $out/passwd\n");
for u in &cfg.users {
let uid = u.uid.unwrap_or(1000);
let gid = u.gid.unwrap_or(uid);
let home = u
.home
.clone()
.unwrap_or_else(|| format!("/home/{}", u.name));
let shell = u.shell.clone().unwrap_or_else(|| "/bin/sh".into());
etc_script.push_str(&format!(
"echo '{n}:x:{uid}:{gid}::{home}:{shell}' >> $out/passwd\n",
n = u.name,
));
}
}
let etc = hermetic_derivation(
format!("etc-{}", sanitize(&cfg.hostname)),
None,
&etc_script,
);
let mut services = BTreeMap::new();
for svc in &cfg.services {
let unit = format!(
"[Unit]\nDescription=tatara-os service: {name}\n\n[Service]\nExecStart={exec}\n{extra}\n\n[Install]\nWantedBy=multi-user.target\n",
name = svc.name,
exec = svc.exec,
extra = svc.extra.join("\n"),
);
let d = hermetic_derivation(
format!("unit-{}", sanitize(&svc.name)),
None,
&format!(
"set -eu\nmkdir -p $out\ncat > $out/{name}.service <<'TATARA_UNIT'\n{unit}TATARA_UNIT\n",
name = svc.name,
unit = unit,
),
);
services.insert(svc.name.clone(), d);
}
let script = ActivationScript::render(cfg).as_file_text();
let activation = hermetic_derivation(
format!("activate-{}", sanitize(&cfg.hostname)),
None,
&format!(
"set -eu\nmkdir -p $out\ncat > $out/activate <<'TATARA_ACTIVATE'\n{}\nTATARA_ACTIVATE\nchmod +x $out/activate\n",
script
),
);
let mut profile_script = String::from(
"set -eu\nmkdir -p $out\n\
ln -s ${kernel} $out/kernel\n\
ln -s ${etc} $out/etc\n\
ln -s ${activation}/activate $out/activate\n",
);
for name in services.keys() {
profile_script.push_str(&format!(
"ln -s ${{{name}}} $out/services/{name}\n",
name = name
));
}
profile_script.push_str("mkdir -p $out/services\n");
let profile = hermetic_derivation(
format!("profile-{}", sanitize(&cfg.hostname)),
None,
&profile_script,
);
Ok(Self {
hostname: cfg.hostname.clone(),
system: cfg.system.clone(),
kernel,
etc,
services,
activation,
profile,
})
}
pub fn all_derivations(&self) -> Vec<&Derivation> {
let mut out: Vec<&Derivation> = vec![&self.kernel, &self.etc, &self.activation];
for d in self.services.values() {
out.push(d);
}
out.push(&self.profile);
out
}
}
fn bridge_derivation(name: String, bridge: BridgeTarget) -> Derivation {
Derivation {
name,
version: None,
inputs: vec![],
source: Source::default(),
builder: BuilderPhases::default(),
outputs: Outputs::default(),
env: vec![],
sandbox: Default::default(),
bridge: Some(bridge),
nix_expr: None,
}
}
fn hermetic_derivation(name: String, version: Option<String>, install_cmd: &str) -> Derivation {
let mut commands = BTreeMap::new();
commands.insert("Install".to_string(), vec![install_cmd.to_string()]);
Derivation {
name,
version,
inputs: vec![],
source: Source::Inline {
content: String::new(),
},
builder: BuilderPhases {
phases: vec![BuilderPhase::Install],
commands,
},
outputs: Outputs::default(),
env: vec![],
sandbox: Default::default(),
bridge: None,
nix_expr: None,
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
fn shell_escape(s: &str) -> String {
s.replace('\'', "'\\''")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{InitSystem, ServiceSpec, UserSpec};
use tatara_pkgs::NixpkgsBridge;
fn basic_cfg() -> SystemConfig {
SystemConfig {
hostname: "plex".into(),
system: "x86_64-linux".into(),
kernel: KernelSpec::Bridge {
attr_path: "linuxPackages.kernel".into(),
},
bootloader: Default::default(),
init: InitSystem::Systemd,
services: vec![ServiceSpec {
name: "nginx".into(),
exec: "nginx -g 'daemon off;'".into(),
enable: true,
extra: vec![],
package_refs: vec![],
}],
users: vec![UserSpec {
name: "drzzln".into(),
uid: Some(1000),
gid: Some(1000),
home: Some("/home/drzzln".into()),
shell: Some("/bin/zsh".into()),
groups: vec![],
}],
filesystems: vec![],
environment: Default::default(),
packages: vec![],
sshd: None,
}
}
#[test]
fn closure_builds_with_all_parts() {
let pkgs = NixpkgsBridge::new();
let closure = SystemClosure::from_config(&basic_cfg(), &pkgs).unwrap();
assert_eq!(closure.hostname, "plex");
assert!(closure.kernel.bridge.is_some());
assert_eq!(
closure.kernel.bridge.unwrap().attr_path,
"linuxPackages.kernel"
);
assert_eq!(closure.services.len(), 1);
assert!(closure.services.contains_key("nginx"));
}
#[test]
fn closure_derivations_have_deterministic_paths() {
let pkgs = NixpkgsBridge::new();
let a = SystemClosure::from_config(&basic_cfg(), &pkgs).unwrap();
let b = SystemClosure::from_config(&basic_cfg(), &pkgs).unwrap();
assert_eq!(a.profile.store_path(), b.profile.store_path());
assert_eq!(a.etc.store_path(), b.etc.store_path());
}
#[test]
fn kernel_custom_derivation_passes_through() {
let pkgs = NixpkgsBridge::new();
let mut cfg = basic_cfg();
let custom = hermetic_derivation(
"my-kernel".into(),
Some("6.1.0".into()),
"mkdir -p $out && echo stub > $out/stub",
);
cfg.kernel = KernelSpec::Custom {
derivation: custom.clone(),
};
let closure = SystemClosure::from_config(&cfg, &pkgs).unwrap();
assert_eq!(closure.kernel.name, "my-kernel");
assert!(closure.kernel.bridge.is_none());
}
#[test]
fn missing_package_kernel_errors() {
let pkgs = NixpkgsBridge::new().with_names(vec!["hello".into()]);
let mut cfg = basic_cfg();
cfg.kernel = KernelSpec::Package {
name: "nonexistent".into(),
};
let err = SystemClosure::from_config(&cfg, &pkgs).unwrap_err();
assert!(matches!(err, SystemClosureError::MissingKernel(_)));
}
#[test]
fn all_derivations_includes_every_part() {
let pkgs = NixpkgsBridge::new();
let closure = SystemClosure::from_config(&basic_cfg(), &pkgs).unwrap();
let all = closure.all_derivations();
assert_eq!(all.len(), 5);
}
}