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