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<Message>) {
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<Message>) -> Result<PathBuf> {
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<B: Future<Output = Result<PathBuf>>>(
realise: impl Future<Output = Result<PathBuf>>,
build_deadline: Instant,
build: impl FnOnce(Duration) -> B,
) -> Result<PathBuf> {
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<Message>,
) -> Result<PathBuf> {
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<Message>,
) -> Result<PathBuf> {
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<Message>,
) -> Result<CaptureOutput> {
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}");
}
}