use crate::command::{CaptureOutput, OutKind, Spec, run_capture, spawn_streaming}; use crate::nix_config::{SPINDLE_RUN_DIR, nix_executable}; use crate::protocol::{self, Message, v1}; use anyhow::{Context, Result}; use std::fs; use std::future::Future; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tokio::sync::mpsc::Sender; use tracing::{info, warn}; const USER_CONFIG_DIR: &str = "/run/spindle/user-config"; const DEVSHELL_ENV_PATH: &str = "/run/spindle/devshell-env.sh"; const DEVSHELL_DRV: &str = "/etc/spindle/devshell-drv"; const MIN_BUILD_TIME: Duration = Duration::from_secs(1); pub async fn run(id: String, req: v1::ActivateConfig, out: Sender) { let config_key = req.config_key.clone(); let result = activate(&id, &req, &out).await; let msg = Message { id, activate_config_result: Some(v1::ActivateConfigResult { config_key, toplevel: (result.as_ref()) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(), error: protocol::error_or_empty(result.err().map(|e| format!("{e:#}"))), }), ..Default::default() }; let _ = out.send(msg).await; } async fn activate(id: &str, req: &v1::ActivateConfig, out: &Sender) -> Result { let need_build = req.toplevel.is_empty(); let requested = (req.timeout_seconds > 0).then(|| Duration::from_secs(req.timeout_seconds.into())); let build_timeout = requested.unwrap_or(Duration::from_secs(10 * 60)); let build_deadline = Instant::now() + build_timeout; let timeout = if need_build { build_timeout } else { requested.unwrap_or(Duration::from_secs(2 * 60)) }; let toplevel = if need_build { build_toplevel(id, req, timeout, out).await? } else { realise_or_build( realise_toplevel(id, &req.toplevel, timeout, out), build_deadline, |left| build_toplevel(id, req, left, out), ) .await? }; if !toplevel.starts_with("/nix/store/") { anyhow::bail!("config toplevel {toplevel:?} is not a nix store path"); } switch_to_configuration(&toplevel, timeout).await?; write_devshell_env(timeout).await?; info!( config_key = %req.config_key, base_config_hash = %req.base_config_hash, ?toplevel, "activated NixOS config" ); Ok(toplevel) } // the cache can evict part of the closure, so a failed realise falls back to building async fn realise_or_build>>( realise: impl Future>, build_deadline: Instant, build: impl FnOnce(Duration) -> B, ) -> Result { match realise.await { Ok(toplevel) => Ok(toplevel), Err(error) => { let left = build_deadline.saturating_duration_since(Instant::now()); if left < MIN_BUILD_TIME { return Err( error.context("no time left to build the config after realising failed") ); } warn!("realising cached config failed, building it: {error:#}"); build(left).await } } } async fn write_devshell_env(timeout: Duration) -> Result<()> { let pointer = Path::new(DEVSHELL_DRV); if !pointer.exists() { let _ = fs::remove_file(DEVSHELL_ENV_PATH); return Ok(()); } let drv = fs::read_to_string(pointer) .with_context(|| format!("read {pointer:?}"))? .trim() .to_owned(); info!( ?drv, "running nix print-dev-env for dependencies devshell..." ); let output = run_capture( Spec::new(nix_executable()) .args(["print-dev-env", "--show-trace", &drv]) .cwd(SPINDLE_RUN_DIR) .timeout(timeout), ) .await?; if !output.success() { anyhow::bail!( "nix print-dev-env failed: exit={} error={:?} output={}", output.exit.exit_code, output.exit.error, output.combined_lossy(), ); } fs::write(DEVSHELL_ENV_PATH, &output.stdout) .with_context(|| format!("write {DEVSHELL_ENV_PATH}"))?; info!(path = %DEVSHELL_ENV_PATH, "wrote devshell env"); Ok(()) } async fn build_toplevel( id: &str, req: &v1::ActivateConfig, timeout: Duration, out: &Sender, ) -> Result { let user_config = (req.user_config.is_empty()) .then_some("{}") .unwrap_or_else(|| &req.user_config); info!("writing user config to {USER_CONFIG_DIR}/config.json"); write_user_config(user_config).context("write user config")?; info!("running nix build command for user config toplevel..."); let output = run_streaming_stderr( Spec::new(nix_executable()) .args([ "build", "--no-link", "--show-trace", "--json", "--file", "/etc/spindle/nixos/default.nix", ]) .cwd(SPINDLE_RUN_DIR) .timeout(timeout), id, out, ) .await?; if !output.success() { anyhow::bail!( "nix config build failed: exit={} error={:?} output={}", output.exit.exit_code, output.exit.error, output.combined_lossy(), ); } #[derive(Debug, serde::Deserialize)] struct NixBuildResult { outputs: NixBuildOutputs, } #[derive(Debug, serde::Deserialize)] struct NixBuildOutputs { out: PathBuf, } let [result] = serde_json::from_slice::<[NixBuildResult; 1]>(&output.stdout) .context("parse nix build --json output")?; Ok(result.outputs.out) } fn write_user_config(user_config: &str) -> Result<()> { fs::create_dir_all(USER_CONFIG_DIR).with_context(|| format!("create {USER_CONFIG_DIR}"))?; let config_path = format!("{USER_CONFIG_DIR}/config.json"); fs::write(&config_path, user_config).with_context(|| format!("write {config_path}"))?; Ok(()) } async fn realise_toplevel( id: &str, toplevel: &str, timeout: Duration, out: &Sender, ) -> Result { if !toplevel.starts_with("/nix/store/") { anyhow::bail!("cached config toplevel {toplevel:?} is not a nix store path"); } let output = run_streaming_stderr( Spec::new(nix_executable()) .args(["build", "--no-link", "--show-trace", toplevel]) .timeout(timeout), id, out, ) .await?; if !output.success() { anyhow::bail!( "realise cached config failed: exit={} error={:?} output={}", output.exit.exit_code, output.exit.error, output.combined_lossy(), ); } Ok(PathBuf::from(toplevel)) } // streams stderr but captures stdout async fn run_streaming_stderr( spec: Spec, id: &str, out: &Sender, ) -> Result { let running = spawn_streaming(spec)?; let mut stdout = Vec::new(); let mut stderr = Vec::new(); let (mut events, exit_task) = running.into_parts(); while let Some(event) = events.recv().await { match event.kind { OutKind::Stdout => stdout.extend_from_slice(&event.data), OutKind::Stderr => { stderr.extend_from_slice(&event.data); let _ = out .send(Message { id: id.to_owned(), exec_stderr: Some(v1::ExecStderr { data: event.data.into(), }), ..Default::default() }) .await; } } } let exit = exit_task .await .unwrap_or_else(|error| Err(anyhow::anyhow!("command supervisor failed: {error}")))?; Ok(CaptureOutput { exit, stdout, stderr, }) } async fn switch_to_configuration(toplevel: &Path, timeout: Duration) -> Result<()> { info!("switching to new configuration: {:?}", toplevel); let switch = toplevel.join("bin/switch-to-configuration"); let output = run_capture(Spec::new(switch).args(["test"]).timeout(timeout)).await?; if !output.success() { anyhow::bail!( "switch-to-configuration test failed: exit={} error={:?} output={}", output.exit.exit_code, output.exit.error, output.combined_lossy(), ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn keeps_the_cached_toplevel_when_it_realises() { let toplevel = realise_or_build( async { Ok(PathBuf::from("/nix/store/cached-nixos-system")) }, Instant::now() + Duration::from_secs(60), |_| async { anyhow::bail!("must not build") }, ) .await .unwrap(); assert_eq!(toplevel, PathBuf::from("/nix/store/cached-nixos-system")); } #[tokio::test] async fn builds_the_config_with_the_time_the_realise_left() { let total = Duration::from_secs(5); let mut given = Duration::ZERO; let toplevel = realise_or_build( async { tokio::time::sleep(Duration::from_millis(300)).await; anyhow::bail!("realise cached config failed") }, Instant::now() + total, |left| { given = left; async { Ok(PathBuf::from("/nix/store/built-nixos-system")) } }, ) .await .unwrap(); assert_eq!(toplevel, PathBuf::from("/nix/store/built-nixos-system")); assert!(given <= total - Duration::from_millis(300), "{given:?}"); assert!(given > Duration::from_secs(3), "{given:?}"); } #[tokio::test] async fn fails_without_building_when_the_realise_used_the_deadline() { let error = realise_or_build( async { anyhow::bail!("realise cached config failed") }, Instant::now(), |_| async { anyhow::bail!("must not build") }, ) .await .unwrap_err(); let error = format!("{error:#}"); assert!(error.contains("no time left"), "{error}"); assert!(error.contains("realise cached config failed"), "{error}"); } }