Monorepo for Tangled
tangled.org
1use crate::command::{self, Spec, run_capture};
2use crate::nix_config::{SPINDLE_RUN_DIR, nix_executable};
3use crate::protocol::{self, Message, v1};
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8use tokio::sync::mpsc::Sender;
9use tracing::info;
10
11const USER_CONFIG_DIR: &str = "/run/spindle/user-config";
12
13pub async fn run(id: String, req: v1::ActivateConfig, out: Sender<Message>) {
14 let config_key = req.config_key.clone();
15 let result = activate(&req).await;
16 let msg = Message {
17 id,
18 activate_config_result: Some(v1::ActivateConfigResult {
19 config_key,
20 toplevel: (result.as_ref())
21 .map(|p| p.to_string_lossy().into_owned())
22 .unwrap_or_default(),
23 error: protocol::error_or_empty(result.err().map(|e| format!("{e:#}"))),
24 }),
25 ..Default::default()
26 };
27 let _ = out.send(msg).await;
28}
29
30async fn activate(req: &v1::ActivateConfig) -> Result<PathBuf> {
31 let need_build = req.toplevel.is_empty();
32 let timeout = (req.timeout_seconds > 0)
33 .then(|| Duration::from_secs(u64::from(req.timeout_seconds)))
34 .or_else(|| need_build.then_some(Duration::from_secs(10 * 60)))
35 .unwrap_or(Duration::from_secs(2 * 60));
36
37 let toplevel = if need_build {
38 build_toplevel(req, timeout).await?
39 } else {
40 realise_toplevel(&req.toplevel, timeout).await?
41 };
42
43 if !toplevel.starts_with("/nix/store/") {
44 anyhow::bail!("config toplevel {toplevel:?} is not a nix store path");
45 }
46
47 switch_to_configuration(&toplevel, timeout).await?;
48 info!(
49 config_key = %req.config_key,
50 base_config_hash = %req.base_config_hash,
51 ?toplevel,
52 "activated NixOS config"
53 );
54 Ok(toplevel)
55}
56
57async fn build_toplevel(req: &v1::ActivateConfig, timeout: Duration) -> Result<PathBuf> {
58 let user_config = (req.user_config.is_empty())
59 .then_some("{}")
60 .unwrap_or_else(|| &req.user_config);
61
62 info!("writing user config to {USER_CONFIG_DIR}/config.json");
63 write_user_config(user_config).context("write user config")?;
64
65 info!("running nix build command for user config toplevel...");
66 let output = run_capture(
67 Spec::new(nix_executable())
68 .args([
69 "build",
70 "--no-link",
71 "--show-trace",
72 "--json",
73 "--file",
74 "/etc/spindle/nixos/default.nix",
75 ])
76 .cwd(SPINDLE_RUN_DIR)
77 .timeout(timeout),
78 )
79 .await?;
80
81 if !output.success() {
82 anyhow::bail!(
83 "nix config build failed: exit={} error={:?} output={}",
84 output.exit.exit_code,
85 output.exit.error,
86 output.combined_lossy(),
87 );
88 }
89
90 #[derive(Debug, serde::Deserialize)]
91 struct NixBuildResult {
92 outputs: NixBuildOutputs,
93 }
94 #[derive(Debug, serde::Deserialize)]
95 struct NixBuildOutputs {
96 out: PathBuf,
97 }
98 let [result] = serde_json::from_slice::<[NixBuildResult; 1]>(&output.stdout)
99 .context("parse nix build --json output")?;
100 Ok(result.outputs.out)
101}
102
103fn write_user_config(user_config: &str) -> Result<()> {
104 fs::create_dir_all(USER_CONFIG_DIR).with_context(|| format!("create {USER_CONFIG_DIR}"))?;
105
106 let config_path = format!("{USER_CONFIG_DIR}/config.json");
107 fs::write(&config_path, user_config).with_context(|| format!("write {config_path}"))?;
108 Ok(())
109}
110
111async fn realise_toplevel(toplevel: &str, timeout: Duration) -> Result<PathBuf> {
112 if !toplevel.starts_with("/nix/store/") {
113 anyhow::bail!("cached config toplevel {toplevel:?} is not a nix store path");
114 }
115 let output = command::run_capture(
116 Spec::new(nix_executable())
117 .args(["build", "--no-link", "--show-trace", toplevel])
118 .timeout(timeout),
119 )
120 .await?;
121 if !output.success() {
122 anyhow::bail!(
123 "realise cached config failed: exit={} error={:?} output={}",
124 output.exit.exit_code,
125 output.exit.error,
126 output.combined_lossy(),
127 );
128 }
129
130 Ok(PathBuf::from(toplevel))
131}
132
133async fn switch_to_configuration(toplevel: &Path, timeout: Duration) -> Result<()> {
134 info!("switching to new configuration: {:?}", toplevel);
135 let switch = toplevel.join("bin/switch-to-configuration");
136 let output = run_capture(Spec::new(switch).args(["test"]).timeout(timeout)).await?;
137 if !output.success() {
138 anyhow::bail!(
139 "switch-to-configuration test failed: exit={} error={:?} output={}",
140 output.exit.exit_code,
141 output.exit.error,
142 output.combined_lossy(),
143 );
144 }
145 Ok(())
146}