Monorepo for Tangled
tangled.org
1use crate::command::{CaptureOutput, OutKind, Spec, run_capture, spawn_streaming};
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";
12const DEVSHELL_ENV_PATH: &str = "/run/spindle/devshell-env.sh";
13const DEVSHELL_DRV: &str = "/etc/spindle/devshell-drv";
14
15pub async fn run(id: String, req: v1::ActivateConfig, out: Sender<Message>) {
16 let config_key = req.config_key.clone();
17 let result = activate(&id, &req, &out).await;
18 let msg = Message {
19 id,
20 activate_config_result: Some(v1::ActivateConfigResult {
21 config_key,
22 toplevel: (result.as_ref())
23 .map(|p| p.to_string_lossy().into_owned())
24 .unwrap_or_default(),
25 error: protocol::error_or_empty(result.err().map(|e| format!("{e:#}"))),
26 }),
27 ..Default::default()
28 };
29 let _ = out.send(msg).await;
30}
31
32async fn activate(id: &str, req: &v1::ActivateConfig, out: &Sender<Message>) -> Result<PathBuf> {
33 let need_build = req.toplevel.is_empty();
34 let timeout = (req.timeout_seconds > 0)
35 .then(|| Duration::from_secs(u64::from(req.timeout_seconds)))
36 .or_else(|| need_build.then_some(Duration::from_secs(10 * 60)))
37 .unwrap_or(Duration::from_secs(2 * 60));
38
39 let toplevel = if need_build {
40 build_toplevel(id, req, timeout, out).await?
41 } else {
42 realise_toplevel(id, &req.toplevel, timeout, out).await?
43 };
44
45 if !toplevel.starts_with("/nix/store/") {
46 anyhow::bail!("config toplevel {toplevel:?} is not a nix store path");
47 }
48
49 switch_to_configuration(&toplevel, timeout).await?;
50 write_devshell_env(timeout).await?;
51 info!(
52 config_key = %req.config_key,
53 base_config_hash = %req.base_config_hash,
54 ?toplevel,
55 "activated NixOS config"
56 );
57 Ok(toplevel)
58}
59
60async fn write_devshell_env(timeout: Duration) -> Result<()> {
61 let pointer = Path::new(DEVSHELL_DRV);
62 if !pointer.exists() {
63 let _ = fs::remove_file(DEVSHELL_ENV_PATH);
64 return Ok(());
65 }
66 let drv = fs::read_to_string(&pointer)
67 .with_context(|| format!("read {pointer:?}"))?
68 .trim()
69 .to_owned();
70
71 info!(
72 ?drv,
73 "running nix print-dev-env for dependencies devshell..."
74 );
75 let output = run_capture(
76 Spec::new(nix_executable())
77 .args(["print-dev-env", "--show-trace", &drv])
78 .cwd(SPINDLE_RUN_DIR)
79 .timeout(timeout),
80 )
81 .await?;
82
83 if !output.success() {
84 anyhow::bail!(
85 "nix print-dev-env failed: exit={} error={:?} output={}",
86 output.exit.exit_code,
87 output.exit.error,
88 output.combined_lossy(),
89 );
90 }
91
92 fs::write(DEVSHELL_ENV_PATH, &output.stdout)
93 .with_context(|| format!("write {DEVSHELL_ENV_PATH}"))?;
94 info!(path = %DEVSHELL_ENV_PATH, "wrote devshell env");
95 Ok(())
96}
97
98async fn build_toplevel(
99 id: &str,
100 req: &v1::ActivateConfig,
101 timeout: Duration,
102 out: &Sender<Message>,
103) -> Result<PathBuf> {
104 let user_config = (req.user_config.is_empty())
105 .then_some("{}")
106 .unwrap_or_else(|| &req.user_config);
107
108 info!("writing user config to {USER_CONFIG_DIR}/config.json");
109 write_user_config(user_config).context("write user config")?;
110
111 info!("running nix build command for user config toplevel...");
112 let output = run_streaming_stderr(
113 Spec::new(nix_executable())
114 .args([
115 "build",
116 "--no-link",
117 "--show-trace",
118 "--json",
119 "--file",
120 "/etc/spindle/nixos/default.nix",
121 ])
122 .cwd(SPINDLE_RUN_DIR)
123 .timeout(timeout),
124 id,
125 out,
126 )
127 .await?;
128
129 if !output.success() {
130 anyhow::bail!(
131 "nix config build failed: exit={} error={:?} output={}",
132 output.exit.exit_code,
133 output.exit.error,
134 output.combined_lossy(),
135 );
136 }
137
138 #[derive(Debug, serde::Deserialize)]
139 struct NixBuildResult {
140 outputs: NixBuildOutputs,
141 }
142 #[derive(Debug, serde::Deserialize)]
143 struct NixBuildOutputs {
144 out: PathBuf,
145 }
146 let [result] = serde_json::from_slice::<[NixBuildResult; 1]>(&output.stdout)
147 .context("parse nix build --json output")?;
148 Ok(result.outputs.out)
149}
150
151fn write_user_config(user_config: &str) -> Result<()> {
152 fs::create_dir_all(USER_CONFIG_DIR).with_context(|| format!("create {USER_CONFIG_DIR}"))?;
153
154 let config_path = format!("{USER_CONFIG_DIR}/config.json");
155 fs::write(&config_path, user_config).with_context(|| format!("write {config_path}"))?;
156 Ok(())
157}
158
159async fn realise_toplevel(
160 id: &str,
161 toplevel: &str,
162 timeout: Duration,
163 out: &Sender<Message>,
164) -> Result<PathBuf> {
165 if !toplevel.starts_with("/nix/store/") {
166 anyhow::bail!("cached config toplevel {toplevel:?} is not a nix store path");
167 }
168 let output = run_streaming_stderr(
169 Spec::new(nix_executable())
170 .args(["build", "--no-link", "--show-trace", toplevel])
171 .timeout(timeout),
172 id,
173 out,
174 )
175 .await?;
176 if !output.success() {
177 anyhow::bail!(
178 "realise cached config failed: exit={} error={:?} output={}",
179 output.exit.exit_code,
180 output.exit.error,
181 output.combined_lossy(),
182 );
183 }
184
185 Ok(PathBuf::from(toplevel))
186}
187
188// streams stderr but captures stdout
189async fn run_streaming_stderr(
190 spec: Spec,
191 id: &str,
192 out: &Sender<Message>,
193) -> Result<CaptureOutput> {
194 let running = spawn_streaming(spec)?;
195 let mut stdout = Vec::new();
196 let mut stderr = Vec::new();
197 let (mut events, exit_task) = running.into_parts();
198
199 while let Some(event) = events.recv().await {
200 match event.kind {
201 OutKind::Stdout => stdout.extend_from_slice(&event.data),
202 OutKind::Stderr => {
203 stderr.extend_from_slice(&event.data);
204 let data = String::from_utf8_lossy(&event.data).into_owned();
205 let _ = out
206 .send(Message {
207 id: id.to_owned(),
208 exec_stderr: Some(v1::ExecStderr { data }),
209 ..Default::default()
210 })
211 .await;
212 }
213 }
214 }
215
216 let exit = exit_task
217 .await
218 .unwrap_or_else(|error| Err(anyhow::anyhow!("command supervisor failed: {error}")))?;
219 Ok(CaptureOutput {
220 exit,
221 stdout,
222 stderr,
223 })
224}
225
226async fn switch_to_configuration(toplevel: &Path, timeout: Duration) -> Result<()> {
227 info!("switching to new configuration: {:?}", toplevel);
228 let switch = toplevel.join("bin/switch-to-configuration");
229 let output = run_capture(Spec::new(switch).args(["test"]).timeout(timeout)).await?;
230 if !output.success() {
231 anyhow::bail!(
232 "switch-to-configuration test failed: exit={} error={:?} output={}",
233 output.exit.exit_code,
234 output.exit.error,
235 output.combined_lossy(),
236 );
237 }
238 Ok(())
239}