forked from
tangled.org/core
Monorepo for Tangled
1use crate::command::{self, Spec};
2use crate::protocol::v1;
3use anyhow::{Context, Result};
4use nix::sys::signal::{self, Signal};
5use nix::unistd::Pid;
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::fmt::Write as _;
9use std::fs;
10use std::io::Write as _;
11use std::os::unix::fs::PermissionsExt;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use tempfile::Builder;
15use tracing::{info, warn};
16
17pub const SPINDLE_RUN_DIR: &str = "/run/spindle";
18pub const SPINDLE_NIX_CONFIG: &str = "/run/spindle/nix.conf";
19pub const SPINDLE_CACHE_CONFIG: &str = "/run/spindle/cache.json";
20pub const SYSTEMCTL_EXECUTABLE: &str = "/run/current-system/sw/bin/systemctl";
21
22// nix lives in different places depending on the guest OS (NixOS system
23// profile vs. plain /usr/local on e.g. alpine)
24pub fn nix_executable() -> &'static str {
25 static NIX: once_cell::sync::Lazy<&'static str> = once_cell::sync::Lazy::new(|| {
26 let paths = [
27 "/run/current-system/sw/bin/nix",
28 "/usr/local/bin/nix",
29 "/usr/bin/nix",
30 ];
31 for candidate in paths {
32 if Path::new(candidate).exists() {
33 return candidate;
34 }
35 }
36 "/run/current-system/sw/bin/nix"
37 });
38 &NIX
39}
40
41#[derive(Clone, Debug, Default, Deserialize, Serialize)]
42pub struct RuntimeCacheConfig {
43 pub read_urls: Vec<String>,
44 pub trusted_public_keys: Vec<String>,
45}
46
47// configures nix daemon with the configuration passed from host
48pub async fn configure(init: &v1::Init, read_proxy_url: &str) -> Result<RuntimeCacheConfig> {
49 let read_urls = vec![read_proxy_url.to_owned()];
50 let cfg = RuntimeCacheConfig {
51 read_urls,
52 trusted_public_keys: clean_strings(&init.cache_trusted_public_keys),
53 };
54
55 if cfg.read_urls.is_empty() && cfg.trusted_public_keys.is_empty() {
56 remove_if_exists(SPINDLE_NIX_CONFIG)?;
57 remove_if_exists(SPINDLE_CACHE_CONFIG)?;
58 return Ok(cfg);
59 }
60
61 fs::create_dir_all(SPINDLE_RUN_DIR).with_context(|| format!("create {SPINDLE_RUN_DIR}"))?;
62
63 let cache_json = serde_json::to_vec_pretty(&cfg)?;
64 write_file_atomic(SPINDLE_CACHE_CONFIG, &cache_json, 0o600)?;
65
66 let mut nix_conf = String::new();
67 if !cfg.read_urls.is_empty() {
68 writeln!(
69 &mut nix_conf,
70 "extra-substituters = {}",
71 cfg.read_urls.join(" ")
72 )
73 .unwrap();
74 }
75 if !cfg.trusted_public_keys.is_empty() {
76 writeln!(
77 &mut nix_conf,
78 "extra-trusted-public-keys = {}",
79 cfg.trusted_public_keys.join(" ")
80 )
81 .unwrap();
82 }
83
84 if nix_conf.is_empty() {
85 remove_if_exists(SPINDLE_NIX_CONFIG)?;
86 return Ok(cfg);
87 }
88
89 write_file_atomic(SPINDLE_NIX_CONFIG, nix_conf.as_bytes(), 0o644)?;
90 restart_nix_daemon().await;
91 info!(
92 read_urls = ?cfg.read_urls,
93 trusted_public_keys = cfg.trusted_public_keys.len(),
94 "configured nix cache"
95 );
96
97 Ok(cfg)
98}
99
100pub fn clean_strings(values: &[String]) -> Vec<String> {
101 let mut seen = HashSet::new();
102 let mut out = Vec::with_capacity(values.len());
103
104 for value in values {
105 let value = value.trim();
106 if value.is_empty() || !seen.insert(value.to_owned()) {
107 continue;
108 }
109 out.push(value.to_owned());
110 }
111
112 out
113}
114
115pub fn clean_store_paths(values: &[String]) -> Vec<String> {
116 clean_strings(values)
117 .into_iter()
118 .filter(|value| value.starts_with("/nix/store/"))
119 .collect()
120}
121
122pub async fn nix_version() -> String {
123 let spec = Spec::new(nix_executable())
124 .arg("--version")
125 .timeout(Duration::from_secs(1));
126
127 let Ok(output) = command::run_capture(spec).await else {
128 return String::new();
129 };
130 if !output.success() {
131 return String::new();
132 }
133
134 String::from_utf8_lossy(&output.stdout).trim().to_owned()
135}
136
137fn write_file_atomic(path: impl AsRef<Path>, data: &[u8], mode: u32) -> Result<()> {
138 let path = path.as_ref();
139 let dir = path.parent().unwrap_or_else(|| Path::new("."));
140 let prefix = path
141 .file_name()
142 .and_then(|name| name.to_str())
143 .map(|name| format!(".{name}.tmp-"))
144 .unwrap_or_else(|| ".tmp-".to_owned());
145
146 let mut tmp = Builder::new()
147 .prefix(&prefix)
148 .permissions(fs::Permissions::from_mode(mode))
149 .tempfile_in(dir)
150 .with_context(|| format!("create temp file for {}", path.display()))?;
151
152 // no separate sync here because we don't need to be crash-safe (this is an
153 // ephemeral vm) only atomicity is needed
154 tmp.write_all(data)
155 .with_context(|| format!("write temp file for {}", path.display()))?;
156 tmp.persist(path)
157 .map(|_| ())
158 .map_err(|err| err.error)
159 .with_context(|| format!("install {}", path.display()))
160}
161
162const NIX_DAEMON_SOCKET: &str = "/nix/var/nix/daemon-socket/socket";
163
164async fn restart_nix_daemon() {
165 if Path::new(SYSTEMCTL_EXECUTABLE).exists() {
166 let spec = Spec::new(SYSTEMCTL_EXECUTABLE)
167 .args(["try-restart", "nix-daemon.service"])
168 .timeout(Duration::from_secs(5));
169 match command::run_capture(spec).await {
170 Ok(output) if output.success() => {}
171 Ok(output) => warn!(
172 exit_code = output.exit.exit_code,
173 error = ?output.exit.error,
174 output = %output.combined_lossy(),
175 "nix-daemon restart failed"
176 ),
177 Err(error) => warn!(%error, "nix-daemon restart failed"),
178 }
179 return;
180 }
181
182 let old_pids = nix_daemon_pids();
183 if old_pids.is_empty() {
184 info!("no nix-daemon running, skipping restart");
185 return;
186 }
187 for pid in &old_pids {
188 if let Err(error) = signal::kill(*pid, Signal::SIGTERM) {
189 warn!(%error, pid = pid.as_raw(), "failed to signal nix-daemon");
190 }
191 }
192
193 // wait for nix daemon to be gone, kill is not sync
194 wait_pids_gone(&old_pids, Duration::from_secs(5)).await;
195 wait_for_nix_daemon_socket(Duration::from_secs(5)).await;
196}
197
198fn nix_daemon_pids() -> Vec<Pid> {
199 let self_pid = std::process::id();
200 let Ok(entries) = fs::read_dir("/proc") else {
201 return Vec::new();
202 };
203 entries
204 .flatten()
205 .filter_map(|entry| {
206 let pid = entry.file_name().to_str()?.parse::<i32>().ok()?;
207 if pid as u32 == self_pid {
208 return None;
209 }
210 let comm = fs::read_to_string(entry.path().join("comm")).ok()?;
211 (comm.trim() == "nix-daemon").then(|| Pid::from_raw(pid))
212 })
213 .collect()
214}
215
216async fn wait_pids_gone(pids: &[Pid], timeout: Duration) {
217 let deadline = tokio::time::Instant::now() + timeout;
218 loop {
219 // signal 0 only checks for existence; Err (ESRCH) means it's gone
220 if !pids.iter().any(|pid| signal::kill(*pid, None).is_ok()) {
221 return;
222 }
223 if tokio::time::Instant::now() >= deadline {
224 warn!("old nix-daemon did not exit before restart timeout");
225 return;
226 }
227 tokio::time::sleep(Duration::from_millis(10)).await;
228 }
229}
230
231async fn wait_for_nix_daemon_socket(timeout: Duration) {
232 let deadline = tokio::time::Instant::now() + timeout;
233 loop {
234 if tokio::net::UnixStream::connect(NIX_DAEMON_SOCKET)
235 .await
236 .is_ok()
237 {
238 return;
239 }
240 if tokio::time::Instant::now() >= deadline {
241 warn!(
242 socket = NIX_DAEMON_SOCKET,
243 "nix-daemon did not come back after restart"
244 );
245 return;
246 }
247 tokio::time::sleep(Duration::from_millis(100)).await;
248 }
249}
250
251fn remove_if_exists(path: impl AsRef<Path>) -> Result<()> {
252 let path: PathBuf = path.as_ref().to_owned();
253 match fs::remove_file(&path) {
254 Ok(()) => Ok(()),
255 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
256 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
257 }
258}