use anyhow::{Context, Result};
use fs4::fs_std::FileExt;
use std::fs::{self, File, OpenOptions};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::utils::{
log_info, log_success, log_warning, rebuild_timeout, run_command, run_command_output,
run_command_timed,
};
fn acquire_rebuild_lock() -> Result<File> {
acquire_lock_at(&std::env::temp_dir().join("fleet-rebuild.lock"))
}
fn acquire_lock_at(lock_path: &Path) -> Result<File> {
let file = OpenOptions::new()
.create(true)
.write(true)
.open(lock_path)
.with_context(|| format!("failed to open rebuild lock file at {}", lock_path.display()))?;
if FileExt::try_lock_exclusive(&file).is_err() {
log_info("Another rebuild is already in progress — waiting for it to finish...");
FileExt::lock_exclusive(&file).context("failed to acquire rebuild lock")?;
}
Ok(file)
}
pub fn find_flake_root(start: &Path) -> Result<PathBuf> {
let mut dir = start.to_path_buf();
loop {
if dir.join("flake.nix").exists() {
return Ok(dir);
}
if !dir.pop() {
anyhow::bail!(
"Could not find flake.nix in {} or any parent directory",
start.display()
);
}
}
}
fn get_hostname() -> Result<String> {
run_command_output(Command::new("hostname").arg("-s"))
.context("Failed to get hostname")
}
fn command_exists(name: &str) -> bool {
Command::new("which")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_or(false, |s| s.success())
}
fn bootstrap_nix_auth(flake_root: &Path) -> Result<()> {
let home = std::env::var("HOME").unwrap_or_default();
let netrc_path = PathBuf::from(&home).join(".config/nix/netrc");
let access_tokens_path = PathBuf::from(&home).join(".config/nix/access-tokens.conf");
let age_key_path = PathBuf::from(&home).join(".config/sops/age/keys.txt");
if netrc_path.exists() && access_tokens_path.exists() {
return Ok(());
}
if !age_key_path.exists() {
log_warning("No SOPS age key — cannot bootstrap GitHub auth (private flake inputs may fail)");
return Ok(());
}
let secrets_yaml = flake_root.join("secrets.yaml");
if !secrets_yaml.exists() {
return Ok(());
}
let sops_cmd = if command_exists("sops") {
"sops".to_string()
} else {
log_info("sops not in PATH — building from nixpkgs...");
let out = run_command_output(
Command::new("nix")
.args([
"--extra-experimental-features",
"nix-command flakes",
"build",
"--print-out-paths",
"--no-link",
"nixpkgs#sops",
]),
)
.context("Failed to build sops from nixpkgs")?;
format!("{out}/bin/sops")
};
let token_output = Command::new(&sops_cmd)
.args(["--decrypt", "--extract", "[\"github\"][\"ghcr-token\"]"])
.arg(&secrets_yaml)
.env("SOPS_AGE_KEY_FILE", &age_key_path)
.output()
.context("Failed to run sops")?;
if !token_output.status.success() {
let stderr = String::from_utf8_lossy(&token_output.stderr);
log_warning(&format!("Could not decrypt GitHub token: {}", stderr.trim()));
return Ok(());
}
let token = String::from_utf8(token_output.stdout)
.context("GitHub token is not valid UTF-8")?
.trim()
.to_string();
if token.is_empty() {
log_warning("Decrypted GitHub token is empty — skipping auth bootstrap");
return Ok(());
}
let nix_config_dir = PathBuf::from(&home).join(".config/nix");
fs::create_dir_all(&nix_config_dir)?;
if !access_tokens_path.exists() {
fs::write(
&access_tokens_path,
format!("access-tokens = github.com={token}\n"),
)?;
fs::set_permissions(&access_tokens_path, fs::Permissions::from_mode(0o600))?;
log_success("Bootstrapped ~/.config/nix/access-tokens.conf");
}
if !netrc_path.exists() {
fs::write(
&netrc_path,
format!(
"machine api.github.com\nlogin x-access-token\npassword {token}\n\n\
machine github.com\nlogin x-access-token\npassword {token}\n"
),
)?;
fs::set_permissions(&netrc_path, fs::Permissions::from_mode(0o600))?;
log_success("Bootstrapped ~/.config/nix/netrc");
}
Ok(())
}
fn ensure_claude_code() {
if command_exists("claude") {
return;
}
log_info("Claude Code not found — installing via nix profile...");
let status = Command::new("nix")
.args([
"--extra-experimental-features",
"nix-command flakes",
"profile",
"install",
"github:sadjow/claude-code-nix",
])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status();
match status {
Ok(s) if s.success() => log_success("Claude Code installed"),
_ => log_warning("Could not install Claude Code — continuing without it"),
}
}
fn bootstrap_nix_custom_conf() -> Result<()> {
if command_exists("darwin-rebuild")
|| PathBuf::from("/etc/nix/nix.custom.conf.before-nix-darwin").exists()
{
return Ok(());
}
let custom_conf = PathBuf::from("/etc/nix/nix.custom.conf");
let current = fs::read_to_string(&custom_conf).unwrap_or_default();
let has_sandbox = current.lines().any(|l| {
let t = l.trim();
t.starts_with("sandbox") && !t.starts_with('#')
});
let has_trusted = current.lines().any(|l| {
let t = l.trim();
t.starts_with("trusted-users") && !t.starts_with('#')
});
if has_sandbox && has_trusted {
return Ok(());
}
let user = std::env::var("USER").unwrap_or_default();
let mut additions = String::new();
if !has_sandbox {
additions.push_str("\n# Bootstrap: disable sandbox for macOS .app builds\nsandbox = false\n");
}
if !has_trusted {
additions.push_str(&format!(
"\n# Bootstrap: trust current user for --option flags\ntrusted-users = root {user}\n"
));
}
log_info("Configuring nix daemon for bootstrap (sandbox=false, trusted-users)...");
let new_content = format!("{current}{additions}");
let mut cmd = Command::new("sudo");
cmd.args(["tee", "/etc/nix/nix.custom.conf"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null());
let mut child = cmd.spawn().context("Failed to run sudo tee")?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
stdin.write_all(new_content.as_bytes())?;
}
let status = child.wait()?;
if !status.success() {
anyhow::bail!("Failed to write /etc/nix/nix.custom.conf");
}
log_info("Restarting nix daemon to apply settings...");
let _ = Command::new("sudo")
.args(["launchctl", "kickstart", "-k", "system/org.nixos.nix-daemon"])
.status();
std::thread::sleep(std::time::Duration::from_secs(2));
log_success("Nix daemon configured for bootstrap");
Ok(())
}
fn accept_xcode_license() {
if !command_exists("xcodebuild") {
return;
}
let check = Command::new("xcodebuild")
.arg("-license")
.arg("check")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match check {
Ok(s) if s.success() => return, _ => {}
}
log_info("Accepting Xcode license (required for xcodebuild)...");
let status = Command::new("sudo")
.args(["xcodebuild", "-license", "accept"])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status();
match status {
Ok(s) if s.success() => log_success("Xcode license accepted"),
_ => log_warning("Could not accept Xcode license — xcodebuild may fail"),
}
}
fn prepare_etc_for_darwin() -> Result<()> {
let managed_files = [
"/etc/hosts",
"/etc/nix/nix.custom.conf",
"/etc/shells",
"/etc/bashrc",
"/etc/zshrc",
];
for path in &managed_files {
let p = PathBuf::from(path);
let backup = PathBuf::from(format!("{path}.before-nix-darwin"));
if p.exists() && !p.is_symlink() && !backup.exists() {
log_info(&format!(
"Moving {path} → {path}.before-nix-darwin (nix-darwin will manage it)"
));
let status = Command::new("sudo")
.args(["mv", path, &format!("{path}.before-nix-darwin")])
.stdin(std::process::Stdio::inherit())
.status()
.context(format!("Failed to run sudo mv for {path}"))?;
if !status.success() {
anyhow::bail!(
"Failed to move {path} → {path}.before-nix-darwin (sudo denied?). \
Run manually: sudo mv {path} {path}.before-nix-darwin"
);
}
}
}
Ok(())
}
const GITOPS_CONFIG: &str = "/etc/pleme-gitops/config.yaml";
fn report_gitops_health() {
if !Path::new(GITOPS_CONFIG).exists() {
return; }
let out = match Command::new("sentinela")
.args(["--config", GITOPS_CONFIG, "status"])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return,
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out) else {
return;
};
match gitops_verdict(&v) {
GitopsVerdict::Converged(line) => log_info(&line),
GitopsVerdict::Degraded { headline, detail } => {
log_warning(&headline);
print!("{detail}");
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum GitopsVerdict {
Converged(String),
Degraded { headline: String, detail: String },
}
fn gitops_verdict(v: &serde_json::Value) -> GitopsVerdict {
let streak = v["consecutive_failures"].as_u64().unwrap_or(0);
let verified = v["chain_verified"].as_bool().unwrap_or(true);
let last_ok = v["last_activated_rev"].as_str();
let kind = v["head"]["outcome"]["kind"].as_str().unwrap_or("unknown");
let last_activated = last_ok.map_or_else(|| "never".to_owned(), short_rev);
if last_ok.is_none() && v["receipts"].as_u64() == Some(0) {
return GitopsVerdict::Degraded {
headline: "gitops: NO DEPLOY RECORDED — this loop has never converged".to_owned(),
detail: format!(
" receipts : {} (chain has no activation)\n \
note : expected on a freshly-enrolled node; \
investigate if it persists past one poll interval\n \
detail : sentinela --config {GITOPS_CONFIG} status\n",
v["receipts"].as_u64().unwrap_or(0)
),
};
}
if streak == 0 && verified {
return GitopsVerdict::Converged(format!("gitops: converged (last activated {last_activated})"));
}
let mut detail = String::new();
detail.push_str(&format!(" head receipt : {kind} ({streak} consecutive)\n"));
detail.push_str(&format!(" last activated : {last_activated}\n"));
if !verified {
detail.push_str(" chain : FAILED VERIFICATION (truncated or reordered)\n");
}
if let Some(err) = v["head"]["outcome"]["error"].as_str() {
let head_line = err.lines().next().unwrap_or(err);
detail.push_str(&format!(" last error : {head_line}\n"));
}
detail.push_str(&format!(" detail : sentinela --config {GITOPS_CONFIG} status\n"));
GitopsVerdict::Degraded {
headline: "gitops: DEGRADED — the node is not tracking the branch".to_owned(),
detail,
}
}
fn short_rev(rev: &str) -> String {
rev.get(..7).unwrap_or(rev).to_owned()
}
pub fn rebuild(show_trace: bool, nix_options: &[String]) -> Result<()> {
let _rebuild_lock = acquire_rebuild_lock()?;
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let flake_root = find_flake_root(&cwd)?;
let hostname = get_hostname()?;
if std::env::consts::OS == "macos" {
let user = std::env::var("USER").unwrap_or_default();
let prefix = format!(
"/run/current-system/sw/bin:/etc/profiles/per-user/{user}/bin:/nix/var/nix/profiles/default/bin"
);
let existing = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{prefix}:{existing}"));
}
log_info(&format!(
"Rebuilding {} (flake at {})",
hostname,
flake_root.display()
));
report_gitops_health();
if std::env::consts::OS == "macos" {
if let Err(e) = bootstrap_nix_custom_conf() {
log_warning(&format!("Nix daemon config bootstrap: {e} — continuing anyway"));
}
accept_xcode_license();
}
if let Err(e) = bootstrap_nix_auth(&flake_root) {
log_warning(&format!("Auth bootstrap: {e} — continuing anyway"));
}
ensure_claude_code();
match std::env::consts::OS {
"macos" => darwin_rebuild(&flake_root, &hostname, show_trace, nix_options),
"linux" => nixos_rebuild(&flake_root, &hostname, show_trace, nix_options),
os => anyhow::bail!("Unsupported OS: {}", os),
}
}
fn darwin_rebuild(
flake_root: &Path,
hostname: &str,
show_trace: bool,
nix_options: &[String],
) -> Result<()> {
log_info(&format!("Darwin rebuild for {}...", hostname));
let real_user = std::env::var("USER").unwrap_or_default();
let real_home = std::env::var("HOME").unwrap_or_default();
let ssl_cert = std::env::var("NIX_SSL_CERT_FILE")
.unwrap_or_else(|_| "/etc/ssl/certs/ca-certificates.crt".to_string());
let access_tokens_path = PathBuf::from(&real_home).join(".config/nix/access-tokens.conf");
let access_tokens = if access_tokens_path.exists() {
fs::read_to_string(&access_tokens_path)
.ok()
.and_then(|content| {
content
.trim()
.strip_prefix("access-tokens = ")
.map(|v| v.to_string())
})
} else {
None
};
if !command_exists("darwin-rebuild") {
log_warning("darwin-rebuild not in PATH — bootstrapping from flake...");
let mut build_cmd = Command::new("nix");
build_cmd
.args([
"--extra-experimental-features",
"nix-command flakes",
"build",
"--print-out-paths",
"--no-link",
])
.arg(format!(".#darwinConfigurations.{hostname}.system"))
.current_dir(flake_root);
build_cmd.arg("--option").arg("sandbox").arg("false");
if let Some(ref tokens) = access_tokens {
build_cmd.arg("--option").arg("access-tokens").arg(tokens);
}
for pair in nix_options.chunks(2) {
if pair.len() == 2 {
build_cmd.arg("--option").arg(&pair[0]).arg(&pair[1]);
}
}
let system_path = run_command_output(&mut build_cmd)
.context("Failed to build darwin system configuration")?;
prepare_etc_for_darwin()?;
log_info("Activating system profile (bootstrap)...");
let activate = format!("{system_path}/activate");
let mut cmd = Command::new("sudo");
cmd.arg("--preserve-env=HOME,USER,NIX_SSL_CERT_FILE,GIT_SSL_CAINFO")
.env("HOME", &real_home)
.env("USER", &real_user)
.env("NIX_SSL_CERT_FILE", &ssl_cert)
.env("GIT_SSL_CAINFO", &ssl_cert)
.arg(&activate)
.current_dir(flake_root);
run_command(&mut cmd)?;
log_success(&format!("{} bootstrapped successfully", hostname));
return Ok(());
}
if std::env::var("FLEET_SKIP_E2E_GATE").map_or(true, |v| v != "1") {
let system = if cfg!(target_arch = "aarch64") {
"aarch64-darwin"
} else {
"x86_64-darwin"
};
let app_attr = format!(".#apps.{system}.e2e-mado.program");
let has_app = Command::new("nix")
.args(["eval", "--raw", &app_attr])
.current_dir(flake_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_or(false, |s| s.success());
if has_app {
log_info("Running e2e gate against the candidate closure (.#e2e-mado)...");
let status = Command::new("nix")
.args(["run", ".#e2e-mado"])
.current_dir(flake_root)
.status()
.context("Failed to launch the e2e gate")?;
if !status.success() {
anyhow::bail!(
"e2e gate FAILED — the candidate closure's mado/frostmourne smoke matrix did not pass; refusing to switch. Inspect with `nix run .#e2e-mado`; break-glass override: FLEET_SKIP_E2E_GATE=1 (document why)."
);
}
log_success("e2e gate passed — candidate closure verified interactive");
} else {
log_info("e2e gate: no .#e2e-mado app in this flake — skipped");
}
} else {
log_warning("e2e gate SKIPPED via FLEET_SKIP_E2E_GATE=1");
}
prepare_etc_for_darwin()?;
let mut cmd = Command::new("sudo");
cmd.arg("--preserve-env=HOME,USER,NIX_SSL_CERT_FILE,GIT_SSL_CAINFO")
.env("HOME", &real_home)
.env("USER", &real_user)
.env("NIX_SSL_CERT_FILE", &ssl_cert)
.env("GIT_SSL_CAINFO", &ssl_cert)
.arg("darwin-rebuild")
.arg("switch")
.arg("--flake")
.arg(format!(".#{}", hostname))
.current_dir(flake_root);
if show_trace {
cmd.arg("--show-trace");
}
for pair in nix_options.chunks(2) {
if pair.len() == 2 {
cmd.arg("--option").arg(&pair[0]).arg(&pair[1]);
}
}
run_command_timed(&mut cmd, rebuild_timeout())?;
post_rebuild_cleanup();
log_success(&format!("{} rebuilt successfully", hostname));
Ok(())
}
fn nixos_rebuild(
flake_root: &Path,
hostname: &str,
show_trace: bool,
nix_options: &[String],
) -> Result<()> {
log_info(&format!("NixOS rebuild for {}...", hostname));
let mut cmd = Command::new("sudo");
cmd.arg("nixos-rebuild")
.arg("switch")
.arg("--flake")
.arg(format!(".#{}", hostname))
.current_dir(flake_root)
.env("NIX_CONFIG", "experimental-features = nix-command flakes");
if show_trace {
cmd.arg("--show-trace");
}
for pair in nix_options.chunks(2) {
if pair.len() == 2 {
cmd.arg("--option").arg(&pair[0]).arg(&pair[1]);
}
}
run_command_timed(&mut cmd, rebuild_timeout())?;
post_rebuild_cleanup();
log_success(&format!("{} rebuilt successfully", hostname));
Ok(())
}
fn post_rebuild_cleanup() {
if std::env::var("FLEET_REBUILD_CLEANUP").is_ok_and(|v| v == "0") {
return;
}
if !command_exists("seibi") {
return;
}
log_info("post-rebuild: seibi nix-gc --keep-days 14");
let status = Command::new("seibi")
.args(["nix-gc", "--keep-days", "14"])
.status();
match status {
Ok(s) if s.success() => log_success("post-rebuild nix-gc complete"),
Ok(s) => log_warning(&format!(
"post-rebuild nix-gc exited non-zero: {:?}",
s.code()
)),
Err(e) => log_warning(&format!("post-rebuild nix-gc failed to spawn: {e}")),
}
}
#[cfg(test)]
mod rebuild_lock_tests {
use super::*;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn fresh_lock_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("fleet-rebuild-test-{name}-{}.lock", std::process::id()))
}
#[test]
fn second_acquire_blocks_until_first_is_dropped() {
let path = fresh_lock_path("blocks");
let first = acquire_lock_at(&path).expect("first acquire");
let (tx, rx) = mpsc::channel();
let path_clone = path.clone();
let handle = thread::spawn(move || {
tx.send(()).unwrap(); acquire_lock_at(&path_clone).expect("second acquire");
"acquired".to_string()
});
rx.recv().unwrap();
thread::sleep(Duration::from_millis(50));
drop(first);
let result = handle.join().expect("thread panicked");
assert_eq!(result, "acquired");
let _ = fs::remove_file(&path);
}
#[test]
fn lock_is_reentrant_across_sequential_acquisitions() {
let path = fresh_lock_path("sequential");
let first = acquire_lock_at(&path).expect("first acquire");
drop(first);
let second = acquire_lock_at(&path).expect("second acquire after drop");
drop(second);
let _ = fs::remove_file(&path);
}
}
#[cfg(test)]
mod gitops_verdict_tests {
use super::*;
use serde_json::json;
#[test]
fn converged_chain_reports_the_last_activated_rev() {
let v = json!({
"consecutive_failures": 0,
"chain_verified": true,
"last_activated_rev": "da42c8f8d082453e8ad303c55aacbebca1420336",
"head": { "outcome": { "kind": "activated", "generation": 1215 } },
});
assert_eq!(
gitops_verdict(&v),
GitopsVerdict::Converged("gitops: converged (last activated da42c8f)".to_owned())
);
}
#[test]
fn the_ryn_outage_would_have_been_visible_on_every_rebuild() {
let v = json!({
"consecutive_failures": 4136,
"chain_verified": true,
"last_activated_rev": serde_json::Value::Null,
"head": { "outcome": {
"kind": "failed",
"error": "build failed: building the system configuration...\nerror: creating symlink '/result.tmp': Read-only file system",
} },
});
let GitopsVerdict::Degraded { headline, detail } = gitops_verdict(&v) else {
panic!("a 4136-failure streak must not read as converged");
};
assert!(headline.contains("DEGRADED"));
assert!(detail.contains("failed (4136 consecutive)"), "{detail}");
assert!(detail.contains("last activated : never"), "{detail}");
assert!(detail.contains("last error : build failed"), "{detail}");
assert!(!detail.contains("Read-only file system"), "{detail}");
}
#[test]
fn a_broken_chain_is_degraded_even_with_no_failure_streak() {
let v = json!({
"consecutive_failures": 0,
"chain_verified": false,
"last_activated_rev": "0123456789abcdef0123456789abcdef01234567",
"head": { "outcome": { "kind": "activated", "generation": 9 } },
});
let GitopsVerdict::Degraded { detail, .. } = gitops_verdict(&v) else {
panic!("an unverifiable chain must never read as converged");
};
assert!(detail.contains("FAILED VERIFICATION"), "{detail}");
}
#[test]
fn an_empty_chain_is_never_reported_as_converged() {
let v = json!({
"consecutive_failures": 0,
"chain_verified": true,
"last_activated_rev": serde_json::Value::Null,
"receipts": 0,
"head": serde_json::Value::Null,
});
let GitopsVerdict::Degraded { headline, detail } = gitops_verdict(&v) else {
panic!("a chain with no activation must never read as converged");
};
assert!(headline.contains("NO DEPLOY RECORDED"), "{headline}");
assert!(detail.contains("no activation"), "{detail}");
}
#[test]
fn an_unparseable_status_shape_stays_quiet_rather_than_crying_wolf() {
assert!(matches!(
gitops_verdict(&json!({})),
GitopsVerdict::Converged(_)
));
}
}