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";
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(&req).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(req: &v1::ActivateConfig) -> 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(req, timeout).await?
41 } else {
42 realise_toplevel(&req.toplevel, timeout).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 drv = match fs::canonicalize(DEVSHELL_DRV) {
62 Ok(p) => p,
63 Err(_) => {
64 let _ = fs::remove_file(DEVSHELL_ENV_PATH);
65 return Ok(());
66 }
67 };
68
69 info!(
70 ?drv,
71 "running nix print-dev-env for dependencies devshell..."
72 );
73 let output = run_capture(
74 Spec::new(nix_executable())
75 .args([
76 "print-dev-env".into(),
77 "--show-trace".into(),
78 drv.into_os_string(),
79 ])
80 .cwd(SPINDLE_RUN_DIR)
81 .timeout(timeout),
82 )
83 .await?;
84
85 if !output.success() {
86 anyhow::bail!(
87 "nix print-dev-env failed: exit={} error={:?} output={}",
88 output.exit.exit_code,
89 output.exit.error,
90 output.combined_lossy(),
91 );
92 }
93
94 fs::write(DEVSHELL_ENV_PATH, &output.stdout)
95 .with_context(|| format!("write {DEVSHELL_ENV_PATH}"))?;
96 info!(path = %DEVSHELL_ENV_PATH, "wrote devshell env");
97 Ok(())
98}
99
100async fn build_toplevel(req: &v1::ActivateConfig, timeout: Duration) -> Result<PathBuf> {
101 let user_config = (req.user_config.is_empty())
102 .then_some("{}")
103 .unwrap_or_else(|| &req.user_config);
104
105 info!("writing user config to {USER_CONFIG_DIR}/config.json");
106 write_user_config(user_config).context("write user config")?;
107
108 info!("running nix build command for user config toplevel...");
109 let output = run_capture(
110 Spec::new(nix_executable())
111 .args([
112 "build",
113 "--no-link",
114 "--show-trace",
115 "--json",
116 "--file",
117 "/etc/spindle/nixos/default.nix",
118 ])
119 .cwd(SPINDLE_RUN_DIR)
120 .timeout(timeout),
121 )
122 .await?;
123
124 if !output.success() {
125 anyhow::bail!(
126 "nix config build failed: exit={} error={:?} output={}",
127 output.exit.exit_code,
128 output.exit.error,
129 output.combined_lossy(),
130 );
131 }
132
133 #[derive(Debug, serde::Deserialize)]
134 struct NixBuildResult {
135 outputs: NixBuildOutputs,
136 }
137 #[derive(Debug, serde::Deserialize)]
138 struct NixBuildOutputs {
139 out: PathBuf,
140 }
141 let [result] = serde_json::from_slice::<[NixBuildResult; 1]>(&output.stdout)
142 .context("parse nix build --json output")?;
143 Ok(result.outputs.out)
144}
145
146fn write_user_config(user_config: &str) -> Result<()> {
147 fs::create_dir_all(USER_CONFIG_DIR).with_context(|| format!("create {USER_CONFIG_DIR}"))?;
148
149 let config_path = format!("{USER_CONFIG_DIR}/config.json");
150 fs::write(&config_path, user_config).with_context(|| format!("write {config_path}"))?;
151 Ok(())
152}
153
154async fn realise_toplevel(toplevel: &str, timeout: Duration) -> Result<PathBuf> {
155 if !toplevel.starts_with("/nix/store/") {
156 anyhow::bail!("cached config toplevel {toplevel:?} is not a nix store path");
157 }
158 let output = command::run_capture(
159 Spec::new(nix_executable())
160 .args(["build", "--no-link", "--show-trace", toplevel])
161 .timeout(timeout),
162 )
163 .await?;
164 if !output.success() {
165 anyhow::bail!(
166 "realise cached config failed: exit={} error={:?} output={}",
167 output.exit.exit_code,
168 output.exit.error,
169 output.combined_lossy(),
170 );
171 }
172
173 Ok(PathBuf::from(toplevel))
174}
175
176async fn switch_to_configuration(toplevel: &Path, timeout: Duration) -> Result<()> {
177 info!("switching to new configuration: {:?}", toplevel);
178 let switch = toplevel.join("bin/switch-to-configuration");
179 let output = run_capture(Spec::new(switch).args(["test"]).timeout(timeout)).await?;
180 if !output.success() {
181 anyhow::bail!(
182 "switch-to-configuration test failed: exit={} error={:?} output={}",
183 output.exit.exit_code,
184 output.exit.error,
185 output.combined_lossy(),
186 );
187 }
188 Ok(())
189}