Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / shuttle / src / nix_config.rs
8.1 kB 258 lines
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}