From 9209e63211463b425de0e9864249471986701444 Mon Sep 17 00:00:00 2001 From: Yifei Sun Date: Mon, 28 Sep 2026 23:18:06 +0200 Subject: [PATCH 1/3] hive: apply repl expr to flake `colmenaHive` output --- src/nix/hive/mod.rs | 15 ++++- src/nix/hive/tests/makehive-flake/flake.nix | 2 +- src/nix/hive/tests/mod.rs | 72 ++++++++++++++++++--- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/nix/hive/mod.rs b/src/nix/hive/mod.rs index 68f662d0..323586f5 100644 --- a/src/nix/hive/mod.rs +++ b/src/nix/hive/mod.rs @@ -451,7 +451,20 @@ impl Hive { /// Returns the expression for a REPL session. pub fn get_repl_expression(&self) -> String { - format!("{} hive.introspect (x: x)", self.get_base_expression()) + let expression = format!("{} hive.introspect (x: x)", self.get_base_expression()); + + match (self.evaluation_method, self.path()) { + // the base expression is a lambda for nix eval --apply, which means + // nix repl needs it applied to the colmenaHive output here, from the + // unlocked uri that nix eval reads too, so :reload refetches the flake, + // though nix 2.26 to 2.34 cache git work tree status until the repl exits + (EvaluationMethod::DirectFlakeEval, HivePath::Flake(flake)) => format!( + "({}) (builtins.getFlake \"{}\").outputs.colmenaHive", + expression, + flake.uri() + ), + _ => expression, + } } /// Returns the base expression from which the evaluated Hive can be used. diff --git a/src/nix/hive/tests/makehive-flake/flake.nix b/src/nix/hive/tests/makehive-flake/flake.nix index 571ab084..1be43207 100644 --- a/src/nix/hive/tests/makehive-flake/flake.nix +++ b/src/nix/hive/tests/makehive-flake/flake.nix @@ -1,6 +1,6 @@ { inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nixpkgs.follows = "colmena/nixpkgs"; colmena.url = "git+file://@repoPath@"; }; outputs = { nixpkgs, colmena, ... }: { diff --git a/src/nix/hive/tests/mod.rs b/src/nix/hive/tests/mod.rs index 56697f45..604897f9 100644 --- a/src/nix/hive/tests/mod.rs +++ b/src/nix/hive/tests/mod.rs @@ -10,9 +10,9 @@ use std::hash::Hash; use std::io::Write; use std::iter::{FromIterator, Iterator}; use std::ops::Deref; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use tempfile::{Builder as TempFileBuilder, NamedTempFile}; +use tempfile::{Builder as TempFileBuilder, NamedTempFile, TempDir}; use tokio_test::block_on; macro_rules! node { @@ -171,9 +171,9 @@ fn test_parse_simple() { assert_eq!(Some("luser"), host_b.target_user.as_deref()); } -#[test] -fn test_parse_makehive_flake() { - // make a copy of the flake so we can edit the colmena input +/// Copies the makehive flake into a temporary directory with the colmena +/// input pointed at this checkout. +fn makehive_flake_dir() -> TempDir { let src_dir = PathBuf::from("./src/nix/hive/tests/makehive-flake"); let flake_dir = TempFileBuilder::new() .prefix("makehive-flake-") @@ -194,14 +194,23 @@ fn test_parse_makehive_flake() { fs::write(flake_nix, patched_flake).unwrap(); - // run the test - let flake = block_on(Flake::from_dir(flake_dir.as_ref(), &NixFlags::default())).unwrap(); + flake_dir +} + +/// Loads the makehive flake in the given directory as a hive. +fn makehive_flake(flake_dir: &Path) -> Hive { + let flake = block_on(Flake::from_dir(flake_dir, &NixFlags::default())).unwrap(); let mut flags = NixFlags::default(); flags.set_show_trace(true); - let hive_path = HivePath::Flake(flake); - let mut hive = block_on(Hive::new(hive_path, flags)).unwrap(); + block_on(Hive::new(HivePath::Flake(flake), flags)).unwrap() +} + +#[test] +fn test_parse_makehive_flake() { + let flake_dir = makehive_flake_dir(); + let mut hive = makehive_flake(flake_dir.path()); let nodes = block_on(hive.deployment_info()).unwrap(); assert!(set_eq( @@ -233,6 +242,51 @@ fn test_parse_makehive_flake() { drop(flake_dir); } +#[test] +fn test_repl_expression_flake_evaluates() { + let flake_dir = makehive_flake_dir(); + let hive = makehive_flake(flake_dir.path()); + + let mut expr_file = TempFileBuilder::new() + .prefix("colmena-repl-") + .suffix(".nix") + .tempfile() + .unwrap(); + expr_file + .write_all(hive.get_repl_expression().as_bytes()) + .unwrap(); + + // nix repl is impure, and getFlake on an unlocked ref needs that + let mut flags = NixFlags::default(); + flags.set_impure(true); + + let node_names = || { + block_on( + NixCommand::nix(flags.clone()) + .args(["eval", "--file"]) + .arg(expr_file.path()) + .args(["--apply", "s: builtins.attrNames s.nodes"]) + .build() + .capture_output(), + ) + .unwrap() + }; + + let output = node_names(); + assert!(output.contains("host-a")); + assert!(output.contains("host-b")); + + // evaluating the same file again must see an edit, which a locked uri would not + let flake_nix = flake_dir.path().join("flake.nix"); + let edited = fs::read_to_string(&flake_nix).unwrap().replace( + "host-b = {", + "host-c = { boot.isContainer = true; };\n host-b = {", + ); + fs::write(&flake_nix, edited).unwrap(); + + assert!(node_names().contains("host-c")); +} + #[test] fn test_parse_node_references() { TempHive::valid( From 74203c45fdfaa62e6f58e83da3095e02301c917e Mon Sep 17 00:00:00 2001 From: Yifei Sun Date: Thu, 1 Oct 2026 15:58:21 +0200 Subject: [PATCH 2/3] hive: instantiate both hive through eval cmd --- src/nix/hive/mod.rs | 94 +++++++++++++++++++----------- src/nix/hive/tests/mod.rs | 117 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 34 deletions(-) diff --git a/src/nix/hive/mod.rs b/src/nix/hive/mod.rs index 323586f5..63b48ca4 100644 --- a/src/nix/hive/mod.rs +++ b/src/nix/hive/mod.rs @@ -8,6 +8,7 @@ use std::convert::AsRef; use std::path::{Path, PathBuf}; use const_format::formatcp; +use itertools::Itertools; use tokio::process::Command; use tokio::sync::OnceCell; use validator::Validate; @@ -37,6 +38,34 @@ const FLAKE_APPLY_SNIPPET: &str = formatcp!( HIVE_SCHEMA ); +/// The snippet mapping a value to the paths of the derivations in it. +/// +/// It follows `nix-instantiate`: the value and each list element throw +/// unless they are a derivation, a list or a set, an attribute is skipped +/// unless it is a derivation or a set with `recurseForDerivations = true`, +/// and an output other than `out` is printed as `!`. +/// Unlike `nix-instantiate`, it keeps attributes whose names fall outside +/// `[A-Za-z_][A-Za-z0-9_+-]*`, such as a node named `web.example.com`, +/// and calls no function, so a function or a set with `__functor` that is +/// not a derivation throws at the top level or in a list. Forcing +/// `drvPath` writes the derivation to the store under `nix eval` and +/// under `nix-instantiate --eval --read-write-mode` alike. +const DRV_PATHS_SNIPPET: &str = r#"(let + drvPath = v: v.drvPath + (if v.outputName or "out" != "out" then "!" + v.outputName else ""); + isDrv = v: (v.type or null) == "derivation"; + inSet = v: + if isDrv v then [ (drvPath v) ] + else if builtins.isAttrs v && v.recurseForDerivations or false then inAttrs v + else [ ]; + inAttrs = v: builtins.concatMap (n: builtins.addErrorContext "while evaluating the attribute '${n}'" (inSet v.${n})) (builtins.attrNames v); + inList = v: builtins.concatMap (e: if isDrv e then [ (drvPath e) ] else toDrvPaths e) v; + toDrvPaths = v: + if isDrv v then [ (drvPath v) ] + else if builtins.isAttrs v && !(v ? __functor) then inAttrs v + else if builtins.isList v then inList v + else throw "The expression must evaluate to a derivation, or a list or attribute set of derivations"; +in toDrvPaths)"#; + #[derive(Debug, Clone)] pub enum HivePath { /// A Nix Flake. @@ -433,12 +462,23 @@ impl Hive { /// Evaluates an expression using values from the configuration. pub async fn introspect(&self, expression: String, instantiate: bool) -> ColmenaResult { if instantiate { - let expression = format!("hive.introspect ({})", expression); - self.nix_instantiate(&expression) - .instantiate_with_builders() + // the eval command of both hive kinds instantiates the + // derivations whose drvPath it forces + let expression = format!("{} (hive.introspect ({}))", DRV_PATHS_SNIPPET, expression); + let paths: Vec = self + .nix_instantiate(&expression) + .eval_with_builders() .await? - .capture_output() - .await + .capture_json() + .await?; + + // one path per line as nix-instantiate prints them, each path + // once, whereas nix-instantiate skips only a value it has printed + Ok(paths + .into_iter() + .unique() + .map(|path| format!("{}\n", path)) + .collect()) } else { let expression = format!("toJSON (hive.introspect ({}))", expression); self.nix_instantiate(&expression) @@ -503,41 +543,32 @@ impl<'hive> NixInstantiate<'hive> { Self { hive, expression } } - fn instantiate(&self, flags: NixFlags) -> NixCommand { - // TODO: Better error handling - if self.hive.evaluation_method == EvaluationMethod::DirectFlakeEval { - panic!("Instantiation is not supported with DirectFlakeEval"); - } - + fn eval_command(&self, flags: NixFlags) -> NixCommand { let mut full_expression = self.hive.get_base_expression(); full_expression += &self.expression; - let mut command = NixCommand::nix_instantiate(flags); - - if self.hive.is_flake() { - command = command.extra_features(&["flakes"]); - } + match self.hive.evaluation_method { + EvaluationMethod::NixInstantiate => { + let mut command = NixCommand::nix_instantiate(flags); - command.args(["--no-gc-warning", "-E"]).arg(full_expression) - } + if self.hive.is_flake() { + command = command.extra_features(&["flakes"]); + } - fn eval_command(&self, flags: NixFlags) -> NixCommand { - match self.hive.evaluation_method { - EvaluationMethod::NixInstantiate => self - .instantiate(flags) - .args(["--eval", "--json", "--strict"]) - // --read-write-mode instantiates the derivations - // needed for the system profile and IFD - .arg("--read-write-mode"), + command + .arg("-E") + .arg(full_expression) + .args(["--eval", "--json", "--strict"]) + // --read-write-mode instantiates the derivations needed + // for the system profile, IFD and eval --instantiate + .arg("--read-write-mode") + } EvaluationMethod::DirectFlakeEval => { let hive_installable = self .hive .flake_installable() .expect("DirectFlakeEval only supports flakes"); - let mut full_expression = self.hive.get_base_expression(); - full_expression += &self.expression; - NixCommand::nix(flags) .arg("eval") // nix eval .arg(hive_installable) @@ -552,11 +583,6 @@ impl<'hive> NixInstantiate<'hive> { self.eval_command(flags).build() } - async fn instantiate_with_builders(self) -> ColmenaResult { - let flags = self.hive.nix_flags_with_builders().await?; - Ok(self.instantiate(flags).build()) - } - async fn eval_with_builders(self) -> ColmenaResult { let flags = self.hive.nix_flags_with_builders().await?; Ok(self.eval_command(flags).build()) diff --git a/src/nix/hive/tests/mod.rs b/src/nix/hive/tests/mod.rs index 604897f9..a6a7ac6a 100644 --- a/src/nix/hive/tests/mod.rs +++ b/src/nix/hive/tests/mod.rs @@ -287,6 +287,98 @@ fn test_repl_expression_flake_evaluates() { assert!(node_names().contains("host-c")); } +/// Returns an expression for a derivation no earlier run has written. +fn fresh_derivation(name: &str) -> String { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + format!( + r#"derivation {{ name = "colmena-test-{name}-{nonce}"; builder = "/bin/sh"; system = "x86_64-linux"; }}"# + ) +} + +#[test] +fn test_introspect_instantiate_flake() { + let flake_dir = makehive_flake_dir(); + let hive = makehive_flake(flake_dir.path()); + + let expr = format!("{{ ... }}: {}", fresh_derivation("flake")); + let output = block_on(hive.introspect(expr, true)).unwrap(); + + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(1, lines.len()); + assert!(lines[0].ends_with(".drv")); + assert!(Path::new(lines[0]).exists()); + assert!(output.ends_with('\n')); +} + +#[test] +fn test_drv_paths_snippet() { + // fake derivations, nothing is instantiated + let eval = |fixture: &str| { + block_on( + NixCommand::nix(NixFlags::default()) + .args(["eval", "--json", "--expr"]) + .arg(format!( + r#"{} (let fakeDrv = name: {{ type = "derivation"; drvPath = "/nix/store/${{name}}.drv"; outputName = "out"; }}; in {})"#, + DRV_PATHS_SNIPPET, fixture + )) + .build() + .capture_output(), + ) + .unwrap() + }; + + let set = r#"{ + a = fakeDrv "a"; + b = "str"; + c = { recurseForDerivations = true; d = fakeDrv "d"; }; + e = { f = fakeDrv "f"; }; + g = (fakeDrv "g") // { outputName = "bin"; }; + "x.y" = fakeDrv "xy"; + }"#; + assert_eq!( + r#"["/nix/store/a.drv","/nix/store/d.drv","/nix/store/g.drv!bin","/nix/store/xy.drv"]"#, + eval(set).trim() + ); + + let list = r#"[ (fakeDrv "a") [ (fakeDrv "b") ] { c = fakeDrv "c"; } ]"#; + assert_eq!( + r#"["/nix/store/a.drv","/nix/store/b.drv","/nix/store/c.drv"]"#, + eval(list).trim() + ); + + // the bindings of the snippet stay out of the scope of the value + let scope = r#"with { drvPath = fakeDrv "w"; }; drvPath"#; + assert_eq!(r#"["/nix/store/w.drv"]"#, eval(scope).trim()); + + // lix prints the context of an error only with --show-trace + let mut flags = NixFlags::default(); + flags.set_show_trace(true); + + let error = |fixture: &str| { + let mut command = NixCommand::nix(flags.clone()) + .args(["eval", "--expr"]) + .arg(format!("{} ({})", DRV_PATHS_SNIPPET, fixture)) + .build(); + let output = block_on(async { command.output().await }).unwrap(); + + assert!(!output.status.success()); + String::from_utf8(output.stderr).unwrap() + }; + + // a set with __functor throws like a function, where nix-instantiate + // would call it + let functor = "{ __functor = self: x: x; }"; + assert!(error(functor).contains("The expression must evaluate to a derivation")); + + // an error names the attribute it comes from + let attribute = r#"{ a = throw "boom"; }"#; + assert!(error(attribute).contains("while evaluating the attribute 'a'")); +} + #[test] fn test_parse_node_references() { TempHive::valid( @@ -748,6 +840,31 @@ fn test_hive_autocall() { ); } +#[test] +fn test_hive_introspect_instantiate() { + let hive = TempHive::new( + r#" + { + test = { ... }: { + boot.isContainer = true; + }; + } + "#, + ); + + // the same derivation twice prints one path + let expr = format!( + "{{ ... }}: let drv = {}; in [ drv drv ]", + fresh_derivation("hive") + ); + let output = block_on(hive.introspect(expr, true)).unwrap(); + + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(1, lines.len()); + assert!(lines[0].ends_with(".drv")); + assert!(Path::new(lines[0]).exists()); +} + #[test] fn test_hive_introspect() { let hive = TempHive::new( From 8c74c2a044c905ec8b93e76bcdf18041913c3f3a Mon Sep 17 00:00:00 2001 From: Yifei Sun Date: Thu, 1 Oct 2026 15:58:56 +0200 Subject: [PATCH 3/3] integration-tests: add eval, instantiate, repl test coverage --- integration-tests/apply/test-script.py | 14 ++++++++++++++ integration-tests/flakes/default.nix | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/integration-tests/apply/test-script.py b/integration-tests/apply/test-script.py index 2e3c28b4..c1fcd494 100644 --- a/integration-tests/apply/test-script.py +++ b/integration-tests/apply/test-script.py @@ -109,3 +109,17 @@ with subtest("Check that we can build nodes with dots in their names"): deployer.succeed("cd /tmp/bundle &&" \ f"{colmena} build --evaluator {evaluator} --eval-node-limit 4 --on gamma.tld") + +# eval, instantiate and the repl ignore --evaluator +if evaluator == "chunked": + with subtest("Check that eval, instantiate and the repl work"): + deployer.succeed("echo '{ nodes, ... }: builtins.attrNames nodes' > /tmp/names.nix") + names = deployer.succeed(f"cd /tmp/bundle && {colmena} eval /tmp/names.nix") + assert "alpha" in names + + drv = deployer.succeed("cd /tmp/bundle &&" \ + f"{colmena} eval --instantiate -E '{{ nodes, ... }}: nodes.alpha.config.system.build.toplevel'") + assert drv.strip().endswith(".drv") + + repl = deployer.succeed(f"cd /tmp/bundle && echo 'builtins.attrNames nodes' | {colmena} repl") + assert "alpha" in repl diff --git a/integration-tests/flakes/default.nix b/integration-tests/flakes/default.nix index 8ed397d9..53421194 100644 --- a/integration-tests/flakes/default.nix +++ b/integration-tests/flakes/default.nix @@ -36,6 +36,21 @@ tools.runTest { with subtest("Lock flake dependencies"): deployer.succeed("cd /tmp/bundle && nix --extra-experimental-features \"nix-command flakes\" flake lock") + '' + # eval, instantiate and the repl ignore --evaluator and run the same in every variant + + lib.optionalString (evaluator == "chunked" && pure) '' + with subtest("Evaluate, instantiate and inspect the hive"): + names = deployer.succeed("cd /tmp/bundle && ${tools.colmenaExec} eval -E '{ nodes, ... }: builtins.attrNames nodes'") + assert "alpha" in names + + drv = deployer.succeed("cd /tmp/bundle && ${tools.colmenaExec} eval --instantiate -E '{ nodes, ... }: nodes.alpha.config.system.build.toplevel'") + assert drv.strip().endswith(".drv") + + repl = deployer.succeed("cd /tmp/bundle && echo 'builtins.attrNames nodes' | ${tools.colmenaExec} repl") + assert "alpha" in repl + + '' + + '' with subtest("Deploy with a plain flake without git"): deployer.succeed("cd /tmp/bundle && ${tools.colmenaExec} apply --on @target ${applyFlags}") alpha.succeed("grep FIRST /etc/deployment")