Monorepo for Tangled
0

Configure Feed

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

shuttle,nix/microvm: get rid of the hacky nix config parsing / rendering, use nix directly so we can access module options

this allows us to not rely on the "flake#package" ref parsing hack,
and the shorthand-enable improves because we actually can check if
an options path has a .enable option.

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jun 11, 2026, 5:14 PM +0300) c9d43991 ea84d55d

+112 -229
+2 -1
nix/microvm/spindle-vm.nix
··· 20 20 services.tangled.shuttle.package = lib.mkDefault ${self.packages.${pkgs.stdenv.hostPlatform.system}.shuttle}; 21 21 } 22 22 ''; 23 + "spindle/nixos/user-config.nix".source = ./user-config.nix; 23 24 "spindle/nixos/microvm".source = microvm; 24 25 "spindle/nixos/default.nix".text = '' 25 26 { pkgs ? import <nixpkgs> {} }: ··· 30 31 /etc/spindle/nixos/base.nix 31 32 /etc/spindle/nixos/runner.nix 32 33 /etc/spindle/nixos/shuttle.nix 33 - /run/spindle/user-config/config.nix 34 + /etc/spindle/nixos/user-config.nix 34 35 ]; 35 36 services.tangled.shuttle.enable = true; 36 37 };
+97
nix/microvm/user-config.nix
··· 1 + { 2 + pkgs, 3 + lib, 4 + options, 5 + ... 6 + } @ args: let 7 + configPath = /run/spindle/user-config/config.json; 8 + userConfig = 9 + args.userConfig 10 + or ( 11 + if builtins.pathExists configPath 12 + then lib.importJSON configPath 13 + else {} 14 + ); 15 + 16 + registry = userConfig.registry or {}; 17 + 18 + # registry targets may be structured attrs or flake ref strings; strings are 19 + # parsed by nix itself in getFlake. flakeRefToString rejects unforced attr 20 + # values, hence the toJSON round-trip 21 + toRefString = target: 22 + if builtins.isAttrs target 23 + then builtins.flakeRefToString (builtins.fromJSON (builtins.toJSON target)) 24 + else target; 25 + 26 + # user registry entries shadow the system registry (which pins nixpkgs) 27 + getFlake = ref: builtins.getFlake (toRefString (registry.${ref} or ref)); 28 + 29 + # "flakeref#attr" or a bare attr looked up in nixpkgs. nixpkgs refs use the 30 + # already-evaluated pkgs directly instead of re-evaluating via getFlake, 31 + # unless the user remapped nixpkgs in their registry 32 + resolvePackage = ref: let 33 + parts = lib.splitString "#" ref; 34 + hasAttr = lib.length parts > 1; 35 + flakeRef = 36 + if hasAttr 37 + then lib.head parts 38 + else "nixpkgs"; 39 + pkgName = 40 + if hasAttr 41 + then lib.elemAt parts 1 42 + else ref; 43 + system = pkgs.stdenv.hostPlatform.system; 44 + flake = getFlake flakeRef; 45 + notFound = throw "Package ${pkgName} not found in ${flakeRef}"; 46 + in 47 + if flakeRef == "nixpkgs" && !(registry ? nixpkgs) 48 + then pkgs.${pkgName} or notFound 49 + else flake.legacyPackages.${system}.${pkgName} or flake.packages.${system}.${pkgName} or notFound; 50 + 51 + # strings are resolved as package references only where the option type 52 + # actually expects packages; everything else passes through untouched 53 + resolveForType = type: v: 54 + if type.name == "package" && builtins.isString v 55 + then resolvePackage v 56 + else if type.name == "nullOr" && v != null 57 + then resolveForType type.nestedTypes.elemType v 58 + else if type.name == "listOf" && builtins.isList v 59 + then map (resolveForType type.nestedTypes.elemType) v 60 + else if (type.name == "attrsOf" || type.name == "lazyAttrsOf") && builtins.isAttrs v 61 + then builtins.mapAttrs (_: resolveForType type.nestedTypes.elemType) v 62 + else if type.name == "submodule" && builtins.isAttrs v 63 + then resolveOptions (type.getSubOptions []) v 64 + else v; 65 + 66 + resolveOptions = opts: builtins.mapAttrs (name: resolveValue (opts.${name} or null)); 67 + 68 + resolveValue = opt: v: 69 + if !builtins.isAttrs opt 70 + then v 71 + else if lib.isOption opt 72 + then resolveForType opt.type v 73 + else if builtins.isAttrs v 74 + then resolveOptions opt v 75 + else v; 76 + 77 + # `foo = true` is shorthand for `foo.enable = true`, but only when an 78 + # enable option actually exists under foo 79 + hasEnableOption = opt: 80 + builtins.isAttrs opt 81 + && ( 82 + if lib.isOption opt 83 + then (opt.type.getSubOptions opt.loc) ? enable 84 + else opt ? enable && lib.isOption opt.enable 85 + ); 86 + 87 + normalize = opts: name: v: let 88 + opt = opts.${name} or null; 89 + in 90 + if builtins.isBool v && hasEnableOption opt 91 + then {enable = v;} 92 + else resolveValue opt v; 93 + in { 94 + environment.systemPackages = map resolvePackage (userConfig.dependencies or []); 95 + services = builtins.mapAttrs (normalize (options.services or {})) (userConfig.services or {}); 96 + virtualisation = builtins.mapAttrs (normalize (options.virtualisation or {})) (userConfig.virtualisation or {}); 97 + }
+13 -228
shuttle/src/activation.rs
··· 5 5 use serde::Deserialize; 6 6 use serde_json::Value; 7 7 use std::collections::BTreeMap; 8 - use std::fmt::Write as _; 9 8 use std::fs; 10 9 use std::time::Duration; 11 10 use tokio::sync::mpsc::Sender; ··· 59 58 Ok(toplevel) 60 59 } 61 60 62 - #[derive(Debug, Default, Deserialize)] 61 + // mirrors the shape consumed by nix/microvm/user-config.nix; parsed here only 62 + // to reject malformed configs before handing them to a root nix-build 63 + #[derive(Debug, Deserialize)] 64 + #[allow(dead_code)] 63 65 struct UserConfig { 64 66 #[serde(default)] 65 67 services: BTreeMap<String, Value>, ··· 72 74 } 73 75 74 76 async fn build_toplevel(req: &v1::ActivateConfig, timeout: Duration) -> Result<String> { 75 - let user_cfg: UserConfig = if req.user_config.is_empty() { 76 - UserConfig::default() 77 + let user_config = if req.user_config.is_empty() { 78 + "{}" 77 79 } else { 78 - serde_json::from_str(&req.user_config).context("parse user config")? 80 + let _: UserConfig = serde_json::from_str(&req.user_config).context("parse user config")?; 81 + req.user_config.as_str() 79 82 }; 80 83 81 - info!("writing nix registry configuration..."); 82 - write_registry(&user_cfg.registry).context("write nix registry")?; 83 - 84 - info!("writing user config module to {USER_CONFIG_DIR}/config.nix"); 85 - let user_module = render_nix_module(&user_cfg); 86 - write_user_config(&user_module).context("write user config")?; 84 + info!("writing user config to {USER_CONFIG_DIR}/config.json"); 85 + write_user_config(user_config).context("write user config")?; 87 86 88 87 info!("running nix-build command for user config toplevel..."); 89 88 let output = run_capture( ··· 120 119 Ok(toplevel) 121 120 } 122 121 123 - fn write_user_config(nix_module: &str) -> Result<()> { 122 + fn write_user_config(user_config: &str) -> Result<()> { 124 123 fs::create_dir_all(USER_CONFIG_DIR).with_context(|| format!("create {USER_CONFIG_DIR}"))?; 125 124 126 - let module_path = format!("{USER_CONFIG_DIR}/config.nix"); 127 - fs::write(&module_path, nix_module).with_context(|| format!("write {module_path}"))?; 125 + let config_path = format!("{USER_CONFIG_DIR}/config.json"); 126 + fs::write(&config_path, user_config).with_context(|| format!("write {config_path}"))?; 128 127 Ok(()) 129 128 } 130 129 ··· 163 162 } 164 163 Ok(()) 165 164 } 166 - 167 - // todo(dawn): this needs to be a lot better probably using rnix 168 - fn render_nix_module(cfg: &UserConfig) -> String { 169 - let mut out = String::new(); 170 - writeln!(out, "{{ pkgs, lib, ... }}:").unwrap(); 171 - writeln!(out, "let").unwrap(); 172 - writeln!(out, " resolvePackage = ref: let").unwrap(); 173 - writeln!(out, " parts = lib.splitString \"#\" ref;").unwrap(); 174 - writeln!(out, " flakeRef = lib.head parts;").unwrap(); 175 - writeln!(out, " hasAttr = lib.length parts > 1;").unwrap(); 176 - writeln!( 177 - out, 178 - " attr = if hasAttr then lib.elemAt parts 1 else null;" 179 - ) 180 - .unwrap(); 181 - writeln!( 182 - out, 183 - " flake = if hasAttr then builtins.getFlake flakeRef else builtins.getFlake \"nixpkgs\";" 184 - ) 185 - .unwrap(); 186 - writeln!(out, " pkgName = if hasAttr then attr else flakeRef;").unwrap(); 187 - writeln!(out, " pkg =").unwrap(); 188 - writeln!( 189 - out, 190 - " flake.legacyPackages.${{pkgs.system}}.${{pkgName}} or" 191 - ) 192 - .unwrap(); 193 - writeln!(out, " flake.packages.${{pkgs.system}}.${{pkgName}} or").unwrap(); 194 - writeln!(out, " flake.defaultPackage.${{pkgs.system}} or").unwrap(); 195 - writeln!(out, " flake.packages.${{pkgs.system}}.default or").unwrap(); 196 - writeln!( 197 - out, 198 - " (builtins.throw \"Package ${{pkgName}} not found\");" 199 - ) 200 - .unwrap(); 201 - writeln!(out, " in pkg;").unwrap(); 202 - writeln!(out, "in {{").unwrap(); 203 - 204 - // environment.systemPackages 205 - if !cfg.dependencies.is_empty() { 206 - writeln!(out, " environment.systemPackages = [").unwrap(); 207 - for dep in &cfg.dependencies { 208 - writeln!(out, " (resolvePackage {dep:?})").unwrap(); 209 - } 210 - writeln!(out, " ];").unwrap(); 211 - } 212 - 213 - // services 214 - for (svc, settings) in &cfg.services { 215 - match settings { 216 - Value::Bool(b) => { 217 - writeln!(out, " services.{svc}.enable = {b};").unwrap(); 218 - } 219 - Value::Object(map) => { 220 - for (k, v) in map { 221 - let nix_val = json_value_to_nix(v); 222 - writeln!(out, " services.{svc}.{k} = {nix_val};").unwrap(); 223 - } 224 - } 225 - other => { 226 - let nix_val = json_value_to_nix(other); 227 - writeln!(out, " services.{svc} = {nix_val};").unwrap(); 228 - } 229 - } 230 - } 231 - 232 - for (virt, settings) in &cfg.virtualisation { 233 - match settings { 234 - Value::Bool(b) => { 235 - writeln!(out, " virtualisation.{virt}.enable = {b};").unwrap(); 236 - } 237 - Value::Object(map) => { 238 - for (k, v) in map { 239 - let nix_val = json_value_to_nix(v); 240 - writeln!(out, " virtualisation.{virt}.{k} = {nix_val};").unwrap(); 241 - } 242 - } 243 - other => { 244 - let nix_val = json_value_to_nix(other); 245 - writeln!(out, " virtualisation.{virt} = {nix_val};").unwrap(); 246 - } 247 - } 248 - } 249 - 250 - writeln!(out, "}}").unwrap(); 251 - out 252 - } 253 - 254 - // todo: replace with an actual parser (probably just use nix) 255 - fn parse_flake_ref(s: &str) -> Value { 256 - if let Some(path) = s.strip_prefix("github:") { 257 - let parts: Vec<&str> = path.split('/').collect(); 258 - if parts.len() >= 2 { 259 - let owner = parts[0]; 260 - let repo = parts[1]; 261 - let mut map = serde_json::Map::new(); 262 - map.insert("type".to_string(), Value::String("github".to_string())); 263 - map.insert("owner".to_string(), Value::String(owner.to_string())); 264 - map.insert("repo".to_string(), Value::String(repo.to_string())); 265 - if parts.len() >= 3 { 266 - let ref_or_rev = parts[2]; 267 - if ref_or_rev.len() == 40 && ref_or_rev.chars().all(|c| c.is_ascii_hexdigit()) { 268 - map.insert("rev".to_string(), Value::String(ref_or_rev.to_string())); 269 - } else { 270 - map.insert("ref".to_string(), Value::String(ref_or_rev.to_string())); 271 - } 272 - } 273 - return Value::Object(map); 274 - } 275 - } else if let Some(path) = s.strip_prefix("path:") { 276 - let mut map = serde_json::Map::new(); 277 - map.insert("type".to_string(), Value::String("path".to_string())); 278 - map.insert("path".to_string(), Value::String(path.to_string())); 279 - return Value::Object(map); 280 - } else if s.starts_with("git+https://") 281 - || s.starts_with("git+http://") 282 - || s.starts_with("git://") 283 - { 284 - let mut map = serde_json::Map::new(); 285 - map.insert("type".to_string(), Value::String("git".to_string())); 286 - let url = if let Some(stripped) = s.strip_prefix("git+") { 287 - stripped 288 - } else { 289 - s 290 - }; 291 - map.insert("url".to_string(), Value::String(url.to_string())); 292 - return Value::Object(map); 293 - } 294 - 295 - if s.starts_with("https://") || s.starts_with("http://") { 296 - let mut map = serde_json::Map::new(); 297 - map.insert("type".to_string(), Value::String("git".to_string())); 298 - map.insert("url".to_string(), Value::String(s.to_string())); 299 - return Value::Object(map); 300 - } 301 - 302 - let mut map = serde_json::Map::new(); 303 - map.insert("type".to_string(), Value::String("indirect".to_string())); 304 - map.insert("id".to_string(), Value::String(s.to_string())); 305 - Value::Object(map) 306 - } 307 - 308 - fn to_flake_ref(val: &Value) -> Value { 309 - match val { 310 - Value::String(s) => parse_flake_ref(s), 311 - Value::Object(map) => Value::Object(map.clone()), 312 - other => other.clone(), 313 - } 314 - } 315 - 316 - fn write_registry(registry: &BTreeMap<String, Value>) -> Result<()> { 317 - let mut flakes = Vec::new(); 318 - for (name, target) in registry { 319 - let from_obj = serde_json::json!({ 320 - "type": "indirect", 321 - "id": name 322 - }); 323 - let to_obj = to_flake_ref(target); 324 - flakes.push(serde_json::json!({ 325 - "from": from_obj, 326 - "to": to_obj 327 - })); 328 - } 329 - 330 - let registry_json = serde_json::json!({ 331 - "version": 2, 332 - "flakes": flakes 333 - }); 334 - 335 - let dir = "/root/.config/nix"; 336 - fs::create_dir_all(dir).with_context(|| format!("create {dir}"))?; 337 - let path = format!("{dir}/registry.json"); 338 - let content = serde_json::to_string_pretty(&registry_json)?; 339 - fs::write(&path, content).with_context(|| format!("write {path}"))?; 340 - Ok(()) 341 - } 342 - 343 - fn json_value_to_nix(val: &Value) -> String { 344 - match val { 345 - Value::Bool(b) => { 346 - if *b { 347 - "true".into() 348 - } else { 349 - "false".into() 350 - } 351 - } 352 - Value::Number(n) => n.to_string(), 353 - Value::String(s) => format!("{s:?}"), 354 - Value::Array(items) => { 355 - let parts: Vec<String> = items 356 - .iter() 357 - .map(|item| match item { 358 - Value::String(s) => { 359 - if s.contains('#') { 360 - format!("(resolvePackage {s:?})") 361 - } else { 362 - format!("{s:?}") 363 - } 364 - } 365 - other => json_value_to_nix(other), 366 - }) 367 - .collect(); 368 - format!("[ {} ]", parts.join(" ")) 369 - } 370 - Value::Object(map) => { 371 - let parts: Vec<String> = map 372 - .iter() 373 - .map(|(k, v)| format!("{k} = {};", json_value_to_nix(v))) 374 - .collect(); 375 - format!("{{ {} }}", parts.join(" ")) 376 - } 377 - Value::Null => "null".into(), 378 - } 379 - }