use sentinela_config::SentinelaConfig;
use sentinela_core::{CheckResult, CheckState};
use sentinela_core::{
BuildProgress, EnvError, Generation, GitopsEnv, HealthProbe, Heartbeat, PlanOutcome,
PlanVerdict, ProbeCheck, ReceiptChain, RebuildDriver, Rev,
};
use std::fs::File;
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use tsunagu::exec::{BoundedRun, OnTimeout};
use url::Url;
const SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system";
const RUNNING_SYSTEM: &str = "/run/current-system";
const PROBE_TAIL_BYTES: usize = 1024;
const REBUILD_LOCK_PATH: &str = sentinela_config::LEGACY_REBUILD_LOCK_PATH;
pub struct RealEnv {
cfg: SentinelaConfig,
}
impl RealEnv {
#[must_use]
pub fn new(cfg: SentinelaConfig) -> Self {
Self { cfg }
}
fn receipts_path(&self) -> PathBuf {
Path::new(&self.cfg.state_dir).join("receipts.yaml")
}
fn legacy_receipts_path(&self) -> PathBuf {
Path::new(&self.cfg.state_dir).join("receipts.json")
}
pub fn heartbeat_path(&self) -> PathBuf {
Path::new(&self.cfg.state_dir).join("heartbeat.json")
}
pub fn load_heartbeat(&self) -> Option<Heartbeat> {
let raw = std::fs::read_to_string(self.heartbeat_path()).ok()?;
serde_json::from_str(&raw).ok()
}
fn flake_ref(&self, rev: &Rev) -> String {
[
&self.cfg.flake_url,
"/",
rev.as_str(),
"#",
&self.cfg.hostname,
]
.concat()
}
fn probe_url(&self) -> String {
self.cfg.rev_probe.git_url.clone()
}
fn probe_token(&self) -> Option<String> {
let tf = self.cfg.rev_probe.token_file.as_ref()?;
let raw = std::fs::read_to_string(tf).ok()?;
let t = raw.trim();
if t.is_empty() { None } else { Some(t.to_owned()) }
}
fn mirror_is_usable(&self, mirror: &Path) -> bool {
if !mirror.exists() {
return false;
}
let valid_repo = self
.run_git(
{
let mut c = Command::new("git");
c.arg("--git-dir").arg(mirror).arg("rev-parse").arg("--git-dir");
c
},
EnvError::AncestryFailed,
)
.is_ok_and(|o| o.status.success());
if !valid_repo {
return false;
}
match std::fs::read_to_string(mirror.join("config")) {
Ok(cfg) => !cfg.contains('@') || !cfg.contains("url = http"),
Err(_) => false,
}
}
fn apply_git_auth(&self, cmd: &mut Command) {
let Some(token) = self.probe_token() else {
return;
};
let mut header = String::from("Authorization: Basic ");
header.push_str(&base64_std(
["x-access-token:", token.as_str()].concat().as_bytes(),
));
cmd.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
.env("GIT_CONFIG_VALUE_0", &header);
scrub(header);
scrub(token);
}
fn sync_mirror(&self) -> Result<PathBuf, EnvError> {
let url = self.probe_url();
let mirror = Path::new(&self.cfg.state_dir).join("ancestry.git");
if !self.mirror_is_usable(&mirror) {
let staging = Path::new(&self.cfg.state_dir).join("ancestry.git.new");
let _ = std::fs::remove_dir_all(&staging);
let out = self.run_git({ let mut c = Command::new("git");
c.arg("clone")
.arg("--bare")
.arg("--filter=blob:none")
.arg(&url)
.arg(&staging);
self.apply_git_auth(&mut c);c }, EnvError::AncestryFailed)?;
if !out.status.success() {
let _ = std::fs::remove_dir_all(&staging);
return Err(EnvError::AncestryFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
let _ = std::fs::remove_dir_all(&mirror);
if let Err(e) = std::fs::rename(&staging, &mirror) {
let _ = std::fs::remove_dir_all(&staging);
return Err(EnvError::AncestryFailed(e.to_string()));
}
}
let fetch = self.run_git(
{
let mut c = Command::new("git");
c.arg("--git-dir")
.arg(&mirror)
.arg("fetch")
.arg("--quiet")
.arg(&url)
.arg(format!(
"+refs/heads/{}:refs/heads/probe",
self.cfg.rev_probe.branch
));
self.apply_git_auth(&mut c);
c
},
EnvError::AncestryFailed,
)?;
if !fetch.status.success() {
return Err(EnvError::AncestryFailed(
String::from_utf8_lossy(&fetch.stderr).trim().to_owned(),
));
}
Ok(mirror)
}
fn driver(&self) -> Result<Box<dyn RebuildDriver + '_>, EnvError> {
match self.cfg.rebuild_tool {
sentinela_config::RebuildTool::DarwinRebuild
| sentinela_config::RebuildTool::NixosRebuild => Ok(Box::new(ShellDriver { env: self })),
sentinela_config::RebuildTool::Sui => match self.cfg.sui.mode {
sentinela_config::SuiMode::Subprocess => Ok(Box::new(ShellDriver { env: self })),
sentinela_config::SuiMode::Embedded => self.sui_driver(),
},
}
}
#[cfg(feature = "sui-driver")]
fn sui_driver(&self) -> Result<Box<dyn RebuildDriver + '_>, EnvError> {
Ok(Box::new(SuiDriver))
}
#[cfg(not(feature = "sui-driver"))]
#[allow(clippy::unnecessary_wraps, clippy::unused_self)]
fn sui_driver(&self) -> Result<Box<dyn RebuildDriver + '_>, EnvError> {
Err(EnvError::ToolMissing {
tool: "sui (built without the `sui-driver` feature)".to_owned(),
})
}
fn rebuild_cwd(&self) -> &Path {
Path::new(&self.cfg.state_dir)
}
fn run_rebuild_ref(&self, verb: &str, flake_ref: &str) -> Result<std::process::Output, EnvError> {
let mut cmd = Command::new(self.cfg.rebuild_binary());
for word in rebuild_argv(
self.cfg.rebuild_tool,
verb,
flake_ref,
&self.cfg.extra_rebuild_args,
) {
cmd.arg(word);
}
self.anchor_activation(&mut cmd);
let (timeout, on_timeout) = match verb {
"switch" => self.activation_bound(),
_ => (
std::time::Duration::from_secs(self.cfg.build_timeout_seconds),
OnTimeout::KillGroup,
),
};
let silence = std::time::Duration::from_secs(self.cfg.build_silence_seconds);
let capture_path = self.rebuild_capture_path(verb);
let run = BoundedRun::new(&capture_path)
.timeout(timeout)
.on_timeout(on_timeout);
let run = if verb == "switch" || silence.is_zero() {
run
} else {
run.silent_after(silence)
};
let err_quiet = std::time::Duration::from_secs(self.cfg.build_error_quiet_seconds);
let run = if verb == "switch" || err_quiet.is_zero() {
run
} else {
run.error_wedge("error:", err_quiet)
};
run.run(cmd)
.map_err(|e| {
exec_err(self.cfg.rebuild_binary(), &e, |msg| {
Self::rebuild_err(verb, msg)
})
})
}
fn anchor_activation(&self, cmd: &mut Command) {
cmd.current_dir(self.rebuild_cwd());
cmd.process_group(0);
}
fn activation_bound(&self) -> (std::time::Duration, OnTimeout) {
(
std::time::Duration::from_secs(self.cfg.switch_timeout_seconds),
OnTimeout::Abandon,
)
}
fn git_capture_path(&self) -> PathBuf {
Path::new(&self.cfg.state_dir).join("git.out")
}
fn run_git(&self, cmd: Command, err: fn(String) -> EnvError) -> Result<Output, EnvError> {
let capture = self.git_capture_path();
let mut run = BoundedRun::new(&capture).separate_streams();
if self.cfg.git_timeout_seconds > 0 {
run = run
.timeout(std::time::Duration::from_secs(self.cfg.git_timeout_seconds))
.on_timeout(OnTimeout::KillGroup);
}
run.run(cmd).map_err(|e| exec_err("git", &e, err))
}
fn rebuild_capture_path(&self, verb: &str) -> PathBuf {
Path::new(&self.cfg.state_dir).join(format!("rebuild-{verb}.out"))
}
fn rebuild_err(verb: &str, msg: String) -> EnvError {
match verb {
"switch" => EnvError::SwitchFailed(msg),
_ => EnvError::BuildFailed(msg),
}
}
}
impl GitopsEnv for RealEnv {
fn probe_head(&self) -> Result<Option<Rev>, EnvError> {
let url = self.probe_url();
let refspec = ["refs/heads/", self.cfg.rev_probe.branch.as_str()].concat();
let out = self.run_git({ let mut c = Command::new("git");
c.arg("ls-remote")
.arg(&url)
.arg(&refspec);
self.apply_git_auth(&mut c);c }, EnvError::ProbeFailed)?;
if !out.status.success() {
return Err(EnvError::ProbeFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(parse_ls_remote(&stdout))
}
fn is_ancestor(&self, ancestor: &Rev, descendant: &Rev) -> Result<bool, EnvError> {
let mirror = self.sync_mirror()?;
let out = self.run_git({ let mut c = Command::new("git");
c.arg("--git-dir")
.arg(&mirror)
.arg("merge-base")
.arg("--is-ancestor")
.arg(ancestor.as_str())
.arg(descendant.as_str());
self.apply_git_auth(&mut c);c }, EnvError::AncestryFailed)?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(EnvError::AncestryFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
fn build(&self, rev: &Rev) -> Result<(), EnvError> {
let driver = self.driver()?;
let flake_ref = self.flake_ref(rev);
if !driver.reports_progress() {
return driver.build(&flake_ref);
}
let mut sink = |p: BuildProgress| {
let beat = Heartbeat {
at_unix_ms: p.at_unix_ms,
outcome: "building".to_owned(),
phase: sentinela_core::Phase::InFlight,
head_rev: Some(rev.clone()),
poll_seconds: self.cfg.poll_seconds,
budget_seconds: None,
in_flight: Some(p),
verification: None,
probe_error: None,
};
let _ = self.write_heartbeat(&beat);
};
driver.build_streaming(&flake_ref, &mut sink)
}
fn switch(&self, rev: &Rev) -> Result<Generation, EnvError> {
let _lock = self.switch_guard()?;
self.driver()?.switch(&self.flake_ref(rev))?;
Ok(current_generation().unwrap_or(Generation(0)))
}
fn load_chain(&self) -> Result<ReceiptChain, EnvError> {
for path in [self.receipts_path(), self.legacy_receipts_path()] {
match std::fs::read_to_string(&path) {
Ok(s) => {
return serde_yaml::from_str(&s)
.map_err(|e| EnvError::ReceiptIo(e.to_string()));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(EnvError::ReceiptIo(e.to_string())),
}
}
Ok(ReceiptChain::new())
}
fn persist_chain(&self, chain: &ReceiptChain) -> Result<(), EnvError> {
let path = self.receipts_path();
let body = serde_yaml::to_string(chain).map_err(|e| EnvError::ReceiptIo(e.to_string()))?;
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, body.as_bytes()).map_err(|e| EnvError::ReceiptIo(e.to_string()))?;
std::fs::rename(&tmp, &path).map_err(|e| EnvError::ReceiptIo(e.to_string()))?;
Ok(())
}
fn now_unix_ms(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
fn write_heartbeat(&self, beat: &Heartbeat) -> Result<(), EnvError> {
let path = self.heartbeat_path();
let body =
serde_json::to_string_pretty(beat).map_err(|e| EnvError::HeartbeatIo(e.to_string()))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, body.as_bytes()).map_err(|e| EnvError::HeartbeatIo(e.to_string()))?;
std::fs::rename(&tmp, &path).map_err(|e| EnvError::HeartbeatIo(e.to_string()))?;
Ok(())
}
fn current_generation(&self) -> Option<Generation> {
current_generation()
}
fn run_health_probe(&self, probe: &HealthProbe) -> Result<(), String> {
match &probe.check {
ProbeCheck::Command {
argv,
expect_exit,
timeout_seconds,
} => {
let Some((program, args)) = argv.split_first() else {
return Err("empty argv".to_owned());
};
let mut cmd = Command::new(program);
cmd.args(args).stdin(Stdio::null());
cmd.process_group(0);
let capture = Path::new(&self.cfg.state_dir).join("probe.out");
let run = BoundedRun::new(&capture)
.timeout(std::time::Duration::from_secs(*timeout_seconds))
.on_timeout(OnTimeout::KillGroup)
.tail_bytes(PROBE_TAIL_BYTES);
match run.run(cmd) {
Ok(out) => {
let tail = String::from_utf8_lossy(&out.stderr);
match out.status.code() {
Some(c) if c == *expect_exit => Ok(()),
code => Err(probe_evidence(code, *expect_exit, tail.trim())),
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(["not found: ", program].concat())
}
Err(e) => Err(e.to_string()),
}
}
}
}
fn rollback_to(&self, generation: Generation) -> Result<Generation, EnvError> {
let _lock = self.switch_guard()?;
let link = generation_link(generation);
if std::fs::symlink_metadata(&link).is_err() {
return Err(EnvError::RollbackFailed(
[&link, " no longer exists — was it garbage-collected?"].concat(),
));
}
for step in rollback_steps(ActivationPlatform::current(), generation) {
let Some((program, args)) = step.argv.split_first() else {
continue;
};
let mut cmd = Command::new(program);
cmd.args(args);
cmd.envs(step.env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
self.anchor_activation(&mut cmd);
let capture = self.rebuild_capture_path("rollback");
let (timeout, on_timeout) = self.activation_bound();
let out = BoundedRun::new(&capture)
.timeout(timeout)
.on_timeout(on_timeout)
.run(cmd)
.map_err(|e| exec_err(program, &e, EnvError::RollbackFailed))?;
if !out.status.success() {
return Err(EnvError::RollbackFailed(
[program.as_str(), ": ", String::from_utf8_lossy(&out.stderr).trim()].concat(),
));
}
}
match current_generation() {
Some(g) if g == generation => Ok(g),
found => Err(EnvError::RollbackFailed(
[
"the rollback steps succeeded, but generation ",
&generation.to_string(),
" is not running (found: ",
&found.map_or_else(|| "none".to_owned(), |g| g.to_string()),
")",
]
.concat(),
)),
}
}
fn candidates(&self, since: Option<&Rev>, head: &Rev, limit: usize) -> Result<Vec<Rev>, EnvError> {
let mirror = self.sync_mirror().map_err(|e| EnvError::HistoryFailed(e.to_string()))?;
let mut c = Command::new("git");
c.arg("--git-dir")
.arg(&mirror)
.arg("rev-list")
.arg("--first-parent")
.arg(["--max-count=", &limit.to_string()].concat());
match since {
Some(since) => {
c.arg("--ancestry-path")
.arg([since.as_str(), "..", head.as_str()].concat());
}
None => {
c.arg(head.as_str());
}
}
self.apply_git_auth(&mut c);
let out = self.run_git(c, EnvError::HistoryFailed)?;
if !out.status.success() {
return Err(EnvError::HistoryFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| Rev::parse(l).ok())
.collect())
}
fn check_results(&self, rev: &Rev) -> Result<Vec<CheckResult>, EnvError> {
let Some((owner, repo)) = github_slug(&self.cfg.rev_probe.git_url) else {
return Err(EnvError::ChecksUnavailable(
["not a github.com repository: ", &self.cfg.rev_probe.git_url].concat(),
));
};
let base = [
"https://api.github.com/repos/",
&owner,
"/",
&repo,
"/commits/",
rev.as_str(),
]
.concat();
let runs = self.github_get(&[base.as_str(), "/check-runs?per_page=100"].concat())?;
let statuses = self.github_get(&[base.as_str(), "/status?per_page=100"].concat())?;
let mut results = parse_check_runs(&runs);
results.extend(parse_statuses(&statuses));
Ok(results)
}
fn plan(&self, rev: &Rev) -> Result<PlanVerdict, EnvError> {
if !self.cfg.planner.enabled {
return Ok(PlanVerdict::Proceed { key: None });
}
let out = self.run_planner(rev, &["plan"], self.cfg.planner.timeout_seconds)?;
if !out.status.success() {
return Err(EnvError::PlannerBlind(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
));
}
parse_plan(&String::from_utf8_lossy(&out.stdout))
}
fn planning_budget_seconds(&self) -> Option<u64> {
self.cfg
.planner
.enabled
.then(|| self.cfg.planner.timeout_seconds.max(1))
}
fn record_plan(&self, key: &str, outcome: PlanOutcome, why: &str) {
if !self.cfg.planner.enabled {
return;
}
let word = match outcome {
PlanOutcome::Activated => "activated",
PlanOutcome::Failed => "failed",
};
let why: String = why.chars().take(400).collect();
match self.run_planner_with(&["record", key, word, &why], 120) {
Ok(out) if out.status.success() => {}
Ok(out) => tracing::warn!(
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"sentinela: kirikae could not record the outcome"
),
Err(e) => tracing::warn!(error = %e, "sentinela: kirikae could not record the outcome"),
}
}
}
impl RealEnv {
fn switch_guard(&self) -> Result<Option<File>, EnvError> {
let path = Path::new(&self.cfg.switch_lock.path);
if self.cfg.switch_lock.hold {
acquire_switch_lock(path).map(Some)
} else {
probe_switch_lock(path).map(|()| None)
}
}
fn planner_env(&self, rev: Option<&Rev>) -> Vec<(&'static str, String)> {
let kind = match self.cfg.rebuild_tool {
sentinela_config::RebuildTool::DarwinRebuild => "darwin",
_ => "nixos",
};
let mut env = vec![
("KIRIKAE_HOST", self.cfg.hostname.clone()),
("KIRIKAE_KIND", kind.to_owned()),
(
"KIRIKAE_HISTORY",
Path::new(&self.cfg.state_dir)
.join("kirikae.jsonl")
.to_string_lossy()
.into_owned(),
),
(
"KIRIKAE_BACKOFF_BASE_S",
self.cfg.planner.backoff_base_seconds.to_string(),
),
(
"KIRIKAE_BACKOFF_CAP_S",
self.cfg.planner.backoff_cap_seconds.to_string(),
),
];
if let Some(rev) = rev {
env.push((
"KIRIKAE_FLAKE",
[self.cfg.flake_url.as_str(), "/", rev.as_str()].concat(),
));
}
env
}
fn run_planner(&self, rev: &Rev, args: &[&str], timeout_seconds: u64) -> Result<Output, EnvError> {
self.run_planner_env(self.planner_env(Some(rev)), args, timeout_seconds)
}
fn run_planner_with(&self, args: &[&str], timeout_seconds: u64) -> Result<Output, EnvError> {
self.run_planner_env(self.planner_env(None), args, timeout_seconds)
}
fn run_planner_env(
&self,
env: Vec<(&'static str, String)>,
args: &[&str],
timeout_seconds: u64,
) -> Result<Output, EnvError> {
let mut cmd = Command::new(&self.cfg.planner.command);
cmd.args(args).current_dir(self.rebuild_cwd());
for (k, v) in env {
cmd.env(k, v);
}
let capture = Path::new(&self.cfg.state_dir).join("kirikae.out");
let mut run = BoundedRun::new(&capture).separate_streams();
if timeout_seconds > 0 {
run = run
.timeout(std::time::Duration::from_secs(timeout_seconds))
.on_timeout(OnTimeout::KillGroup);
}
run.run(cmd)
.map_err(|e| exec_err(&self.cfg.planner.command, &e, EnvError::PlannerBlind))
}
}
fn parse_plan(stdout: &str) -> Result<PlanVerdict, EnvError> {
let line = stdout
.lines()
.rev()
.find(|l| l.trim_start().starts_with('{'))
.ok_or_else(|| EnvError::PlannerBlind(["no plan in: ", stdout.trim()].concat()))?;
let v: serde_json::Value =
serde_json::from_str(line).map_err(|e| EnvError::PlannerBlind(e.to_string()))?;
let key = v.get("key").and_then(serde_json::Value::as_str).map(str::to_owned);
match v.get("action").and_then(serde_json::Value::as_str) {
Some("hold") => Ok(PlanVerdict::Hold {
why: v
.get("why")
.and_then(serde_json::Value::as_str)
.unwrap_or("held")
.to_owned(),
until_unix_ms: v.get("until_ms").and_then(serde_json::Value::as_u64).unwrap_or(0),
}),
Some(_) => Ok(PlanVerdict::Proceed { key }),
None => Err(EnvError::PlannerBlind(["a plan with no action: ", line].concat())),
}
}
impl RealEnv {
fn github_get(&self, url: &str) -> Result<serde_json::Value, EnvError> {
let mut config = String::from("header = \"Accept: application/vnd.github+json\"\n");
if let Some(token) = self.probe_token() {
config.push_str("header = \"Authorization: Bearer ");
config.push_str(&token);
config.push_str("\"\n");
scrub(token);
}
let mut child = Command::new("curl")
.args(["-sS", "-m", "20", "--config", "-", "-w", "\n%{http_code}", url])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| exec_err("curl", &e, EnvError::ChecksUnavailable))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(config.as_bytes());
}
scrub(config);
let out = child
.wait_with_output()
.map_err(|e| EnvError::ChecksUnavailable(e.to_string()))?;
let text = String::from_utf8_lossy(&out.stdout);
let (body, code) = text.rsplit_once('\n').unwrap_or(("", text.as_ref()));
forge_answer(code.trim(), body, String::from_utf8_lossy(&out.stderr).trim())
}
}
fn forge_answer(code: &str, body: &str, stderr: &str) -> Result<serde_json::Value, EnvError> {
match code {
"200" => serde_json::from_str(body).map_err(|e| {
EnvError::ChecksUnavailable(["unreadable answer from GitHub: ", &e.to_string()].concat())
}),
"000" | "" => Err(EnvError::ChecksUnavailable(
["GitHub unreachable: ", stderr].concat(),
)),
"401" => Err(EnvError::ChecksUnavailable(
"GitHub rejected the token (401)".to_owned(),
)),
"403" | "404" => Err(EnvError::ChecksUnavailable(
[
"GitHub answered ",
code,
": the token cannot read this repository's checks (it needs Checks: read and Commit statuses: read)",
]
.concat(),
)),
other => Err(EnvError::ChecksUnavailable(["GitHub answered ", other].concat())),
}
}
fn github_slug(git_url: &str) -> Option<(String, String)> {
let u = Url::parse(git_url).ok()?;
if u.host_str()? != "github.com" {
return None;
}
let mut segs = u.path_segments()?;
let owner = segs.next()?.to_owned();
let repo = segs.next()?.trim_end_matches(".git").to_owned();
(!owner.is_empty() && !repo.is_empty()).then_some((owner, repo))
}
fn parse_check_runs(v: &serde_json::Value) -> Vec<CheckResult> {
let mut newest: Vec<(u64, CheckResult)> = Vec::new();
for run in v.get("check_runs").and_then(|r| r.as_array()).into_iter().flatten() {
let Some(name) = run.get("name").and_then(|n| n.as_str()) else {
continue;
};
let id = run.get("id").and_then(serde_json::Value::as_u64).unwrap_or(0);
let state = match (
run.get("status").and_then(|s| s.as_str()),
run.get("conclusion").and_then(|c| c.as_str()),
) {
(Some("completed"), Some("success")) => CheckState::Success,
(Some("completed"), _) => CheckState::Failed,
_ => CheckState::Pending,
};
let result = CheckResult {
name: name.to_owned(),
state,
};
match newest.iter_mut().find(|(_, r)| r.name == name) {
Some(slot) if slot.0 < id => *slot = (id, result),
Some(_) => {}
None => newest.push((id, result)),
}
}
newest.into_iter().map(|(_, r)| r).collect()
}
fn parse_statuses(v: &serde_json::Value) -> Vec<CheckResult> {
v.get("statuses")
.and_then(|s| s.as_array())
.into_iter()
.flatten()
.filter_map(|st| {
let name = st.get("context")?.as_str()?.to_owned();
let state = match st.get("state")?.as_str()? {
"success" => CheckState::Success,
"pending" => CheckState::Pending,
_ => CheckState::Failed,
};
Some(CheckResult { name, state })
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActivationPlatform {
Darwin,
Nixos,
}
impl ActivationPlatform {
fn current() -> Self {
if cfg!(target_os = "macos") {
Self::Darwin
} else {
Self::Nixos
}
}
}
fn generation_link(generation: Generation) -> String {
[SYSTEM_PROFILE, "-", &generation.0.to_string(), "-link"].concat()
}
fn rollback_steps(platform: ActivationPlatform, generation: Generation) -> Vec<RollbackStep> {
let link = generation_link(generation);
let n = generation.0.to_string();
let step = |argv: Vec<String>| RollbackStep { argv, env: Vec::new() };
match platform {
ActivationPlatform::Darwin => vec![step(vec![
[&link, "/sw/bin/darwin-rebuild"].concat(),
"--switch-generation".to_owned(),
n,
])],
ActivationPlatform::Nixos => vec![
step(vec![
[&link, "/sw/bin/nix-env"].concat(),
"-p".to_owned(),
SYSTEM_PROFILE.to_owned(),
"--switch-generation".to_owned(),
n,
]),
RollbackStep {
argv: vec![
[&link, "/bin/switch-to-configuration"].concat(),
"switch".to_owned(),
],
env: vec![("NIXOS_NO_CHECK".to_owned(), "1".to_owned())],
},
],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RollbackStep {
argv: Vec<String>,
env: Vec<(String, String)>,
}
fn running_generation(
profile: Generation,
running: &str,
target_of: impl Fn(Generation) -> Option<String>,
generations: &[Generation],
) -> Option<Generation> {
if target_of(profile).as_deref() == Some(running) {
return Some(profile);
}
generations
.iter()
.copied()
.filter(|g| target_of(*g).as_deref() == Some(running))
.max_by_key(|g| g.0)
}
fn probe_evidence(code: Option<i32>, expected: i32, tail: &str) -> String {
let status = match code {
Some(c) => ["exit ", &c.to_string()].concat(),
None => "killed by a signal".to_owned(),
};
let mut e = [&status, " (expected ", &expected.to_string(), ")"].concat();
if !tail.is_empty() {
e.push_str(": ");
e.push_str(tail);
}
e
}
fn rebuild_argv(
tool: sentinela_config::RebuildTool,
verb: &str,
flake_ref: &str,
extra: &[String],
) -> Vec<String> {
let mut argv: Vec<String> = tool.argv_prefix().iter().map(|w| (*w).to_owned()).collect();
argv.push(verb.to_owned());
argv.push("--flake".to_owned());
argv.push(flake_ref.to_owned());
argv.extend(extra.iter().cloned());
argv
}
fn exec_err(tool: &str, e: &std::io::Error, fallback: impl FnOnce(String) -> EnvError) -> EnvError {
if e.kind() == std::io::ErrorKind::NotFound {
EnvError::ToolMissing {
tool: tool.to_owned(),
}
} else {
fallback(e.to_string())
}
}
fn acquire_switch_lock(lock_path: &Path) -> Result<File, EnvError> {
use fs4::fs_std::FileExt;
let mut file = File::options()
.create(true)
.write(true)
.open(lock_path)
.map_err(|e| {
EnvError::SwitchBusy(format!(
"cannot open rebuild lock at {}: {e}",
lock_path.display()
))
})?;
let _ = std::fs::set_permissions(lock_path, std::fs::Permissions::from_mode(0o666));
if FileExt::try_lock_exclusive(&file).is_err() {
let holder = std::fs::read_to_string(lock_path)
.map(|h| h.trim().to_owned())
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "unknown".to_owned());
return Err(EnvError::SwitchBusy(holder));
}
let holder = format!("pid {} · sentinela", std::process::id());
let _ = file.set_len(0);
let _ = file.write_all(holder.as_bytes());
Ok(file)
}
fn probe_switch_lock(lock_path: &Path) -> Result<(), EnvError> {
use fs4::fs_std::FileExt;
let file = match File::open(lock_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(EnvError::SwitchBusy(format!(
"cannot read the activation lock at {}: {e}",
lock_path.display()
)));
}
};
if FileExt::try_lock_exclusive(&file).is_err() {
let holder = std::fs::read_to_string(lock_path)
.map(|h| h.trim().to_owned())
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(|| ["an activation holds ", &lock_path.display().to_string()].concat());
return Err(EnvError::SwitchBusy(holder));
}
let _ = FileExt::unlock(&file);
Ok(())
}
fn base64_std(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
T[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
T[n as usize & 63] as char
} else {
'='
});
}
out
}
fn scrub(mut s: String) {
let len = s.len();
s.clear();
s.reserve(len);
for _ in 0..len {
s.push('\0');
}
drop(s);
}
fn parse_ls_remote(stdout: &str) -> Option<Rev> {
let line = stdout.lines().next()?;
let sha = line.split_whitespace().next()?;
Rev::parse(sha).ok()
}
fn parse_generation(link_name: &str) -> Option<Generation> {
link_name
.strip_prefix("system-")?
.strip_suffix("-link")?
.parse::<u64>()
.ok()
.map(Generation)
}
fn current_generation() -> Option<Generation> {
let target = std::fs::read_link(SYSTEM_PROFILE).ok()?;
let name = target.file_name()?.to_str()?;
let profile = parse_generation(name)?;
match ActivationPlatform::current() {
ActivationPlatform::Darwin => Some(profile),
ActivationPlatform::Nixos => {
let canon = |p: &str| {
std::fs::canonicalize(p)
.ok()
.map(|t| t.to_string_lossy().into_owned())
};
let running = canon(RUNNING_SYSTEM)?;
let dir = Path::new(SYSTEM_PROFILE).parent()?;
let generations: Vec<Generation> = std::fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok()?.file_name().to_str().and_then(parse_generation))
.collect();
running_generation(
profile,
&running,
|g| canon(&generation_link(g)),
&generations,
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with_token(token: Option<&str>) -> SentinelaConfig {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let token_file = token.map(|t| {
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let p = std::env::temp_dir().join(
[
"sentinela-test-token-",
&std::process::id().to_string(),
"-",
&n.to_string(),
]
.concat(),
);
std::fs::write(&p, [t, "\n"].concat()).expect("write scratch token");
p.to_string_lossy().into_owned()
});
SentinelaConfig {
rev_probe: sentinela_config::RevProbeConfig {
git_url: "https://github.com/pleme-io/nix.git".to_owned(),
branch: "main".to_owned(),
token_file,
},
..SentinelaConfig::default()
}
}
#[test]
fn the_probe_url_never_carries_a_credential() {
let cfg = cfg_with_token(Some("TKN"));
let env = RealEnv::new(cfg);
let url = env.probe_url();
assert_eq!(
url, "https://github.com/pleme-io/nix.git",
"the URL must be byte-identical to the configured one"
);
assert!(!url.contains("x-access-token"), "no basic-auth userinfo");
assert!(!url.contains("TKN"), "the token must not reach the URL");
assert!(!url.contains('@'), "no userinfo separator at all");
}
#[test]
fn auth_travels_by_env_not_argv_and_the_url_stays_clean() {
let cfg = cfg_with_token(Some("TKN"));
let env = RealEnv::new(cfg);
let mut c = Command::new("git");
c.arg("ls-remote").arg(env.probe_url());
env.apply_git_auth(&mut c);
let argv: Vec<String> = std::iter::once(c.get_program())
.chain(c.get_args())
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!argv.iter().any(|a| a.contains("TKN")),
"the token must never appear in argv, got {argv:?}"
);
let envs: Vec<(String, Option<String>)> = c
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
let get = |key: &str| {
envs.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.clone())
};
assert_eq!(get("GIT_CONFIG_COUNT").as_deref(), Some("1"));
assert_eq!(
get("GIT_CONFIG_KEY_0").as_deref(),
Some("http.extraHeader")
);
assert_eq!(
get("GIT_CONFIG_VALUE_0").as_deref(),
Some("Authorization: Basic eC1hY2Nlc3MtdG9rZW46VEtO"),
"Basic, not Bearer: github's git plane 401s on Bearer even with a \
valid token (measured 2026-08-17)"
);
}
#[test]
fn no_token_configured_means_no_auth_env_at_all() {
let env = RealEnv::new(cfg_with_token(None));
let mut c = Command::new("git");
env.apply_git_auth(&mut c);
assert_eq!(
c.get_envs().count(),
0,
"an unauthenticated probe must not set a half-formed auth config"
);
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_std(b""), "");
assert_eq!(base64_std(b"f"), "Zg==");
assert_eq!(base64_std(b"fo"), "Zm8=");
assert_eq!(base64_std(b"foo"), "Zm9v");
assert_eq!(base64_std(b"foob"), "Zm9vYg==");
assert_eq!(base64_std(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_std(b"foobar"), "Zm9vYmFy");
assert_eq!(base64_std(b"x-access-token:TKN"), "eC1hY2Nlc3MtdG9rZW46VEtO");
}
#[test]
fn parse_ls_remote_takes_the_first_sha() {
let out = "9f1423abcdef0123456789abcdef0123456789ab\trefs/heads/main\n";
assert_eq!(
parse_ls_remote(out).unwrap().as_str(),
"9f1423abcdef0123456789abcdef0123456789ab"
);
}
#[test]
fn parse_ls_remote_empty_is_none() {
assert!(parse_ls_remote("").is_none());
assert!(parse_ls_remote("\n").is_none());
}
#[test]
fn parse_ls_remote_rejects_garbage_sha() {
assert!(parse_ls_remote("not-a-sha\trefs/heads/main\n").is_none());
}
#[test]
fn parse_generation_reads_the_profile_name() {
assert_eq!(parse_generation("system-42-link"), Some(Generation(42)));
assert_eq!(parse_generation("system-1-link"), Some(Generation(1)));
}
#[test]
fn parse_generation_rejects_non_profile_names() {
assert_eq!(parse_generation("system-link"), None);
assert_eq!(parse_generation("42"), None);
assert_eq!(parse_generation("system-abc-link"), None);
}
#[test]
fn a_probed_activation_lock_defers_while_held_and_is_never_kept() {
use fs4::fs_std::FileExt;
let dir = std::env::temp_dir().join(format!("sentinela-probe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("activation.lock");
assert_eq!(probe_switch_lock(&path), Ok(()));
std::fs::write(&path, "").unwrap();
let holder = File::options().write(true).open(&path).unwrap();
FileExt::lock_exclusive(&holder).unwrap();
assert!(matches!(probe_switch_lock(&path), Err(EnvError::SwitchBusy(_))));
FileExt::unlock(&holder).unwrap();
assert_eq!(probe_switch_lock(&path), Ok(()));
let next = File::options().write(true).open(&path).unwrap();
assert!(FileExt::try_lock_exclusive(&next).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_switch_lock_holds_by_default_and_probes_when_configured() {
let env = RealEnv::new(SentinelaConfig::default());
assert!(env.cfg.switch_lock.hold);
assert_eq!(env.cfg.switch_lock.path, REBUILD_LOCK_PATH);
let dir = std::env::temp_dir().join(format!("sentinela-guard-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let probe = RealEnv::new(SentinelaConfig {
switch_lock: sentinela_config::SwitchLockConfig {
path: dir.join("absent.lock").to_string_lossy().into_owned(),
hold: false,
},
..SentinelaConfig::default()
});
assert!(probe.switch_guard().unwrap().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_plan_is_read_from_kirikaes_json() {
let hold = parse_plan(
"{\"action\":\"hold\",\"key\":\"/nix/store/a\",\"until_ms\":65000,\"why\":\"2 failed attempt(s)\"}\n",
)
.unwrap();
assert_eq!(
hold,
PlanVerdict::Hold {
why: "2 failed attempt(s)".to_owned(),
until_unix_ms: 65_000
}
);
assert_eq!(
parse_plan("blue: noise\n{\"action\":\"switch\",\"key\":\"/nix/store/b\"}").unwrap(),
PlanVerdict::Proceed {
key: Some("/nix/store/b".to_owned())
}
);
assert_eq!(
parse_plan("{\"action\":\"refuse\",\"key\":null}").unwrap(),
PlanVerdict::Proceed { key: None }
);
assert!(matches!(parse_plan(""), Err(EnvError::PlannerBlind(_))));
assert!(matches!(parse_plan("{\"why\":\"x\"}"), Err(EnvError::PlannerBlind(_))));
}
#[test]
fn the_planner_is_off_unless_configured_and_names_the_pinned_flake() {
let env = RealEnv::new(SentinelaConfig::default());
assert!(!env.cfg.planner.enabled);
let r = Rev::parse(&"a".repeat(40)).unwrap();
assert_eq!(env.plan(&r).unwrap(), PlanVerdict::Proceed { key: None });
let on = RealEnv::new(SentinelaConfig {
flake_url: "github:pleme-io/nix".to_owned(),
hostname: "ryn".to_owned(),
state_dir: "/var/log/pleme-gitops".to_owned(),
..SentinelaConfig::default()
});
let vars = on.planner_env(Some(&r));
let get = |k: &str| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| v.clone());
assert_eq!(get("KIRIKAE_FLAKE"), Some(["github:pleme-io/nix/", &"a".repeat(40)].concat()));
assert_eq!(get("KIRIKAE_HOST"), Some("ryn".to_owned()));
assert_eq!(get("KIRIKAE_KIND"), Some("darwin".to_owned()));
assert_eq!(get("KIRIKAE_HISTORY"), Some("/var/log/pleme-gitops/kirikae.jsonl".to_owned()));
assert_eq!(get("KIRIKAE_BACKOFF_BASE_S"), Some("300".to_owned()));
assert_eq!(on.planner_env(None).iter().find(|(n, _)| *n == "KIRIKAE_FLAKE"), None);
}
#[test]
fn flake_ref_pins_the_rev() {
let cfg = SentinelaConfig {
flake_url: "github:pleme-io/nix".to_owned(),
hostname: "ryn".to_owned(),
..SentinelaConfig::default()
};
let env = RealEnv::new(cfg);
let rev = Rev::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
assert_eq!(
env.flake_ref(&rev),
"github:pleme-io/nix/0123456789abcdef0123456789abcdef01234567#ryn"
);
}
#[test]
fn rebuild_cwd_is_the_state_dir_never_the_inherited_root() {
let cfg = SentinelaConfig {
state_dir: "/var/log/pleme-gitops".to_owned(),
..SentinelaConfig::default()
};
let env = RealEnv::new(cfg);
assert_eq!(env.rebuild_cwd(), Path::new("/var/log/pleme-gitops"));
assert_ne!(
env.rebuild_cwd(),
Path::new("/"),
"cwd `/` is the read-only system volume — the bug this guards"
);
}
#[test]
fn the_rebuild_lock_path_is_the_same_absolute_path_fleet_uses() {
assert_eq!(REBUILD_LOCK_PATH, "/tmp/fleet-rebuild.lock");
}
#[test]
fn a_held_lock_reports_its_holder() {
let _fork_free = fork_free();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lock");
let _first = acquire_switch_lock(&path).expect("first acquisition");
let err = acquire_switch_lock(&path).expect_err("a held lock must defer");
let expected = format!("pid {} · sentinela", std::process::id());
assert_eq!(
err,
EnvError::SwitchBusy(expected),
"the holder stamp written by the first acquisition must be read back"
);
}
#[test]
fn an_uncontended_lock_is_acquired_and_released_on_drop() {
let _fork_free = fork_free();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lock");
{
let _g = acquire_switch_lock(&path).expect("first acquisition");
assert!(
acquire_switch_lock(&path).is_err(),
"the lock must be held while the guard is alive"
);
}
acquire_switch_lock(&path).expect("the lock must release when the guard drops");
}
#[test]
fn the_lock_file_is_group_and_other_writable() {
let _fork_free = fork_free();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lock");
let _g = acquire_switch_lock(&path).expect("acquire");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o666,
"a default 0644 would hand the creator a monopoly"
);
}
#[test]
fn exec_err_maps_enoent_to_tool_missing() {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
let got = exec_err("git", &e, EnvError::ProbeFailed);
assert_eq!(
got,
EnvError::ToolMissing {
tool: "git".to_owned()
},
"ENOENT on exec means the BINARY is absent — a structural fault \
no retry can clear. Collapsing it into ProbeFailed is what cost \
a live diagnosis on rio."
);
}
#[test]
fn exec_err_keeps_the_callers_variant_for_every_other_io_error() {
let e = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
match exec_err("git", &e, EnvError::ProbeFailed) {
EnvError::ProbeFailed(msg) => assert!(!msg.is_empty()),
other => panic!("transient io error must stay ProbeFailed, got {other:?}"),
}
}
#[test]
fn exec_err_preserves_the_ancestry_variant_too() {
let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
match exec_err("git", &e, EnvError::AncestryFailed) {
EnvError::AncestryFailed(_) => {}
other => panic!("expected AncestryFailed, got {other:?}"),
}
let enoent = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
exec_err("git", &enoent, EnvError::AncestryFailed),
EnvError::ToolMissing {
tool: "git".to_owned()
},
"ENOENT outranks the caller's variant at EVERY site"
);
}
#[test]
fn rebuild_argv_puts_the_verb_first_for_the_nix_tools() {
use sentinela_config::RebuildTool;
let argv = rebuild_argv(
RebuildTool::DarwinRebuild,
"switch",
"github:o/r/abc#h",
&[],
);
assert_eq!(argv, ["switch", "--flake", "github:o/r/abc#h"]);
let argv = rebuild_argv(RebuildTool::NixosRebuild, "build", "github:o/r/abc#h", &[]);
assert_eq!(argv, ["build", "--flake", "github:o/r/abc#h"]);
}
#[test]
fn rebuild_argv_prefixes_suis_subcommand_path_before_the_verb() {
use sentinela_config::RebuildTool;
let argv = rebuild_argv(RebuildTool::Sui, "switch", "/srv/nix/abc#h", &[]);
assert_eq!(
argv,
["system", "rebuild", "switch", "--flake", "/srv/nix/abc#h"]
);
}
#[test]
fn rebuild_argv_keeps_extra_args_last_for_every_tool() {
use sentinela_config::RebuildTool;
let extra = vec!["--option".to_owned(), "foo".to_owned()];
assert_eq!(
rebuild_argv(RebuildTool::DarwinRebuild, "build", "r#h", &extra),
["build", "--flake", "r#h", "--option", "foo"]
);
assert_eq!(
rebuild_argv(RebuildTool::Sui, "build", "r#h", &extra),
[
"system", "rebuild", "build", "--flake", "r#h", "--option", "foo"
]
);
}
#[test]
fn a_missing_rebuild_tool_is_tool_missing_not_a_build_failure() {
let enoent = std::io::Error::from(std::io::ErrorKind::NotFound);
for verb in ["build", "switch"] {
assert_eq!(
exec_err("/run/current-system/sw/bin/nixos-rebuild", &enoent, |m| {
RealEnv::rebuild_err(verb, m)
}),
EnvError::ToolMissing {
tool: "/run/current-system/sw/bin/nixos-rebuild".to_owned()
},
"an absent rebuild tool is structural at EVERY verb"
);
}
}
#[test]
fn a_rebuild_that_blew_its_deadline_stays_a_build_or_switch_failure() {
let timed_out = std::io::Error::from(std::io::ErrorKind::TimedOut);
match exec_err(
"/run/current-system/sw/bin/darwin-rebuild",
&timed_out,
|m| RealEnv::rebuild_err("build", m),
) {
EnvError::BuildFailed(_) => {}
other => panic!("a timeout must stay BuildFailed, got {other:?}"),
}
match exec_err(
"/run/current-system/sw/bin/darwin-rebuild",
&timed_out,
|m| RealEnv::rebuild_err("switch", m),
) {
EnvError::SwitchFailed(_) => {}
other => panic!("a timeout must stay SwitchFailed, got {other:?}"),
}
}
#[test]
fn tool_missing_names_the_binary_in_its_message() {
let msg = EnvError::ToolMissing {
tool: "git".to_owned(),
}
.to_string();
assert!(msg.contains("git"), "message must name the tool: {msg}");
}
static FORK_FREE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn fork_free() -> std::sync::MutexGuard<'static, ()> {
FORK_FREE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn the_sui_mode_selects_the_driver() {
let with = |tool: sentinela_config::RebuildTool, mode: sentinela_config::SuiMode| {
RealEnv::new(SentinelaConfig {
rebuild_tool: tool,
sui: sentinela_config::SuiConfig {
mode,
..sentinela_config::SuiConfig::default()
},
..SentinelaConfig::default()
})
};
let sub = with(sentinela_config::RebuildTool::Sui, sentinela_config::SuiMode::Subprocess);
assert_eq!(sub.driver().map(|d| d.name()).ok(), Some("shell"));
let nix = with(sentinela_config::RebuildTool::NixosRebuild, sentinela_config::SuiMode::Embedded);
assert_eq!(nix.driver().map(|d| d.name()).ok(), Some("shell"));
let emb = with(sentinela_config::RebuildTool::Sui, sentinela_config::SuiMode::Embedded);
if cfg!(feature = "sui-driver") {
assert_eq!(emb.driver().map(|d| d.name()).ok(), Some("sui"));
} else {
assert!(matches!(emb.driver().map(|d| d.name()), Err(EnvError::ToolMissing { .. })));
}
}
fn env_with_state(dir: &std::path::Path) -> RealEnv {
RealEnv::new(SentinelaConfig {
state_dir: dir.to_string_lossy().into_owned(),
..SentinelaConfig::default()
})
}
#[test]
fn run_git_keeps_stdout_separate_or_probe_head_reads_nothing() {
let _fork_free = fork_free();
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
let out = env
.run_git(
{
let mut c = std::process::Command::new("git");
c.arg("--version");
c
},
EnvError::ProbeFailed,
)
.expect("git --version must run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("git version"),
"stdout must survive the bound — probe_head parses it. got stdout={stdout:?}, \
stderr={:?}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn git_timeout_zero_disables_the_bound() {
let _fork_free = fork_free();
let d = tempfile::tempdir().unwrap();
let env = RealEnv::new(SentinelaConfig {
state_dir: d.path().to_string_lossy().into_owned(),
git_timeout_seconds: 0,
..SentinelaConfig::default()
});
let out = env
.run_git(
{
let mut c = std::process::Command::new("git");
c.arg("--version");
c
},
EnvError::ProbeFailed,
)
.expect("the unbounded path must still run the command");
assert!(String::from_utf8_lossy(&out.stdout).contains("git version"));
}
#[test]
fn the_canonical_receipt_path_is_yaml_because_the_bytes_are_yaml() {
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
assert!(
env.receipts_path().ends_with("receipts.yaml"),
"persist_chain serialises with serde_yaml; the name must say so"
);
}
#[test]
fn a_legacy_receipts_json_chain_still_loads() {
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
std::fs::write(
env.legacy_receipts_path(),
"- seq: 0\n rev: cd136f04e14ea67bae9b53491099c63b88a1d3f6\n \
outcome:\n kind: deferred\n newer: \
a891200b72fdef64117de281a232bca0105789c3\n at_unix_ms: \
1785645747419\n prev_hash: null\n",
)
.unwrap();
let chain = env.load_chain().expect("legacy chain must parse");
assert!(
!chain.is_empty(),
"an orphaned chain reads as `never deployed`, which resets the \
failure/deferral streaks the escapes count on — behaviour, not \
just history"
);
}
#[test]
fn the_canonical_path_wins_when_both_exist() {
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
std::fs::write(env.legacy_receipts_path(), "[]\n").unwrap();
std::fs::write(
env.receipts_path(),
"- seq: 7\n rev: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n \
outcome:\n kind: deferred\n newer: \
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n at_unix_ms: 1\n \
prev_hash: null\n",
)
.unwrap();
let chain = env.load_chain().expect("parses");
assert!(
!chain.is_empty(),
"the migrated file is authoritative once written; the legacy copy \
is left on disk for the operator, never re-read over the new one"
);
}
#[test]
fn rollback_steps_use_the_restored_generations_own_tools() {
let argvs = |p, g| -> Vec<Vec<String>> {
rollback_steps(p, g).into_iter().map(|s| s.argv).collect()
};
assert_eq!(
argvs(ActivationPlatform::Darwin, Generation(1710)),
vec![vec![
"/nix/var/nix/profiles/system-1710-link/sw/bin/darwin-rebuild".to_owned(),
"--switch-generation".to_owned(),
"1710".to_owned(),
]]
);
assert_eq!(
argvs(ActivationPlatform::Nixos, Generation(88)),
vec![
vec![
"/nix/var/nix/profiles/system-88-link/sw/bin/nix-env".to_owned(),
"-p".to_owned(),
"/nix/var/nix/profiles/system".to_owned(),
"--switch-generation".to_owned(),
"88".to_owned(),
],
vec![
"/nix/var/nix/profiles/system-88-link/bin/switch-to-configuration".to_owned(),
"switch".to_owned(),
],
]
);
for argvs in [
argvs(ActivationPlatform::Darwin, Generation(3)),
argvs(ActivationPlatform::Nixos, Generation(3)),
] {
for argv in argvs {
assert!(!argv[0].starts_with("/run/current-system"), "{argv:?}");
}
}
}
#[test]
fn a_nixos_rollback_skips_the_pre_switch_checks_and_only_that_step() {
let steps = rollback_steps(ActivationPlatform::Nixos, Generation(88));
let activation = steps.last().expect("an activation step");
assert_eq!(
activation.env,
vec![("NIXOS_NO_CHECK".to_owned(), "1".to_owned())],
"the switch-to-configuration step must carry NIXOS_NO_CHECK=1"
);
assert!(steps[0].env.is_empty(), "the profile flip needs no override");
for s in rollback_steps(ActivationPlatform::Darwin, Generation(3)) {
assert!(s.env.is_empty(), "darwin has no pre-switch checks: {s:?}");
}
}
#[test]
fn the_running_generation_is_the_one_whose_closure_runs() {
let links = |g: Generation| match g.0 {
87 => Some("/nix/store/aaa-system".to_owned()),
88 => Some("/nix/store/bbb-system".to_owned()),
89 => Some("/nix/store/ccc-system".to_owned()),
_ => None,
};
let all = [Generation(87), Generation(88), Generation(89)];
assert_eq!(
running_generation(Generation(89), "/nix/store/ccc-system", links, &all),
Some(Generation(89))
);
assert_eq!(
running_generation(Generation(88), "/nix/store/ccc-system", links, &all),
Some(Generation(89)),
"a profile flipped ahead of a failed activation must not read as rolled back"
);
assert_eq!(
running_generation(Generation(89), "/nix/store/zzz-system", links, &all),
None
);
}
#[test]
fn github_slug_reads_owner_and_repo() {
assert_eq!(
github_slug("https://github.com/pleme-io/nix"),
Some(("pleme-io".to_owned(), "nix".to_owned()))
);
assert_eq!(
github_slug("https://github.com/pleme-io/nix.git"),
Some(("pleme-io".to_owned(), "nix".to_owned()))
);
assert_eq!(github_slug("https://gitlab.com/o/r"), None);
}
#[test]
fn check_runs_take_the_newest_run_and_only_success_is_success() {
let v: serde_json::Value = serde_json::from_str(
r#"{"check_runs":[
{"id":1,"name":"gate","status":"completed","conclusion":"failure"},
{"id":2,"name":"gate","status":"completed","conclusion":"success"},
{"id":3,"name":"blue","status":"in_progress","conclusion":null},
{"id":4,"name":"lint","status":"completed","conclusion":"neutral"}
]}"#,
)
.unwrap();
let r = parse_check_runs(&v);
let state = |n: &str| r.iter().find(|c| c.name == n).map(|c| c.state);
assert_eq!(state("gate"), Some(CheckState::Success), "the re-run supersedes");
assert_eq!(state("blue"), Some(CheckState::Pending));
assert_eq!(state("lint"), Some(CheckState::Failed), "neutral is not success");
}
#[test]
fn statuses_map_by_context() {
let v: serde_json::Value = serde_json::from_str(
r#"{"statuses":[{"context":"ci/a","state":"success"},{"context":"ci/b","state":"pending"},{"context":"ci/c","state":"error"}]}"#,
)
.unwrap();
let r = parse_statuses(&v);
assert_eq!(r.len(), 3);
assert_eq!(r[2].state, CheckState::Failed);
}
#[test]
fn a_rejected_or_underpowered_token_is_blind_and_says_which() {
let e = forge_answer("403", "", "").unwrap_err().to_string();
assert!(e.contains("403") && e.contains("Checks: read"), "{e}");
let e = forge_answer("401", "", "").unwrap_err().to_string();
assert!(e.contains("rejected the token"), "{e}");
let e = forge_answer("000", "", "Could not resolve host").unwrap_err().to_string();
assert!(e.contains("unreachable") && e.contains("resolve"), "{e}");
assert!(forge_answer("200", "{}", "").is_ok());
}
#[test]
fn probe_evidence_names_the_status_the_expectation_and_the_output() {
assert_eq!(
probe_evidence(Some(1), 0, "tailscaled not running"),
"exit 1 (expected 0): tailscaled not running"
);
assert_eq!(probe_evidence(None, 0, ""), "killed by a signal (expected 0)");
}
#[test]
fn a_command_probe_judges_the_exit_status_and_keeps_the_output() {
let _fork_free = fork_free();
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
let probe = |argv: &[&str], expect_exit| HealthProbe {
name: "p".to_owned(),
check: ProbeCheck::Command {
argv: argv.iter().map(|a| (*a).to_owned()).collect(),
expect_exit,
timeout_seconds: 10,
},
};
assert_eq!(env.run_health_probe(&probe(&["/usr/bin/true"], 0)), Ok(()));
assert_eq!(env.run_health_probe(&probe(&["/usr/bin/false"], 1)), Ok(()), "expect_exit is honoured");
let e = env
.run_health_probe(&probe(&["/bin/sh", "-c", "echo no route to host; exit 3"], 0))
.unwrap_err();
assert!(e.starts_with("exit 3 (expected 0)"), "{e}");
assert!(e.contains("no route to host"), "the output tail is the evidence: {e}");
assert_eq!(
env.run_health_probe(&probe(&["/nonexistent/probe"], 0)),
Err("not found: /nonexistent/probe".to_owned())
);
}
#[test]
fn a_probe_that_outlives_its_timeout_fails() {
let _fork_free = fork_free();
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
let started = std::time::Instant::now();
let r = env.run_health_probe(&HealthProbe {
name: "slow".to_owned(),
check: ProbeCheck::Command {
argv: vec!["/bin/sleep".to_owned(), "30".to_owned()],
expect_exit: 0,
timeout_seconds: 1,
},
});
assert!(r.is_err(), "a hung probe must not pass");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "and must not wedge the tick");
}
#[test]
fn no_chain_at_either_path_is_an_empty_chain_not_an_error() {
let d = tempfile::tempdir().unwrap();
let env = env_with_state(d.path());
assert!(env.load_chain().expect("absent is not an error").is_empty());
}
}
pub struct ShellDriver<'a> {
env: &'a RealEnv,
}
impl RebuildDriver for ShellDriver<'_> {
fn build(&self, flake_ref: &str) -> Result<(), EnvError> {
let out = self.env.run_rebuild_ref("build", flake_ref)?;
if out.status.success() {
Ok(())
} else {
Err(EnvError::BuildFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
))
}
}
fn switch(&self, flake_ref: &str) -> Result<(), EnvError> {
let out = self.env.run_rebuild_ref("switch", flake_ref)?;
if out.status.success() {
Ok(())
} else {
Err(EnvError::SwitchFailed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
))
}
}
fn name(&self) -> &'static str {
"shell"
}
}
#[cfg(feature = "sui-driver")]
pub struct SuiDriver;
#[cfg(feature = "sui-driver")]
impl SuiDriver {
fn block_on<F: std::future::Future>(fut: F) -> Result<F::Output, EnvError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| EnvError::BuildFailed(["sui runtime: ", &e.to_string()].concat()))
.map(|rt| rt.block_on(fut))
}
fn orchestrator() -> Result<sui_orchestrate::system::SystemOrchestrator, EnvError> {
sui_orchestrate::system::SystemOrchestrator::new()
.map_err(|e| EnvError::BuildFailed(["sui orchestrator: ", &e.to_string()].concat()))
}
}
#[cfg(feature = "sui-driver")]
impl RebuildDriver for SuiDriver {
fn build(&self, flake_ref: &str) -> Result<(), EnvError> {
let orch = Self::orchestrator()?;
Self::block_on(orch.build_toplevel(flake_ref))?
.map(|_out_path| ())
.map_err(|e| EnvError::BuildFailed(e.to_string()))
}
fn switch(&self, flake_ref: &str) -> Result<(), EnvError> {
let orch = Self::orchestrator()?;
Self::block_on(orch.rebuild_native(flake_ref, sui_orchestrate::system::RebuildAction::Switch))?
.map(|_result| ())
.map_err(|e| EnvError::SwitchFailed(e.to_string()))
}
fn name(&self) -> &'static str {
"sui"
}
}