From a3350b8455529b322a69d1ce5afc15fd2a899582 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Thu, 25 Jun 2026 22:39:01 +0100 Subject: [PATCH 1/3] feat(iaci): parse the `environment` axis in the contract (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema foundation for an env-scoped overlay orthogonal to `profile`: - `defaults.environment` (default env name) + `environments:` map of named overlays on `JetpackFileConfig`, paralleling `profiles:`. - New `JetpackEnvironment { secrets_inventory }` — `Some(vec![])` clears, `None` inherits, mirroring `JetpackProfile`. - `EffectiveDefaults.environment` surfaced by `effective()`. Parse-only this commit: selection (`--environment`) and the overlay-append application land in the parser next. Three schema tests cover the default-env field, the environments map, and the empty-vec-vs-absent distinction. Co-Authored-By: Claude --- src/cli/config_file.rs | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/cli/config_file.rs b/src/cli/config_file.rs index b81e52d..66c3427 100644 --- a/src/cli/config_file.rs +++ b/src/cli/config_file.rs @@ -52,6 +52,10 @@ pub(super) struct JetpackFileConfig { /// Named operator-truth presets. A profile (selected via `--profile` or /// `defaults.profile`) overrides `inventory` + `secrets_inventory` only. pub(super) profiles: Option>, + /// Named environment overlays (selected via `--environment` or + /// `defaults.environment`). An environment layers an env-scoped secrets + /// overlay on top of whatever a profile selected — orthogonal to `profile`. + pub(super) environments: Option>, /// External automation source. **Informational only** for now — parsed so /// the contract validates, but Jetpack does not fetch it yet. pub(super) automation: Option, @@ -68,6 +72,10 @@ pub(super) struct JetpackDefaults { pub(super) roles: Option>, /// The default profile to activate when `--profile` is not given. pub(super) profile: Option, + /// The default environment to activate when `--environment` is not given. + /// Orthogonal to `profile`: an environment layers an env-scoped secrets + /// overlay on top of whatever the profile selected. + pub(super) environment: Option, } /// A named operator-truth preset. When active, a profile overrides only @@ -80,6 +88,16 @@ pub(super) struct JetpackProfile { pub(super) secrets_inventory: Option>, } +/// A named environment overlay. Selected via `--environment` (or +/// `defaults.environment`), it appends its `secrets_inventory` to the load list +/// (loaded last → later-wins) on top of whatever `profile`/`defaults` selected. +/// `None` means "inherit"; `Some(vec![])` means "set to none". +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(super) struct JetpackEnvironment { + pub(super) secrets_inventory: Option>, +} + /// External automation source declaration. **Informational**: parsed and /// surfaced in the resolution summary, but Jetpack does not yet clone/fetch it. #[derive(Debug, Default, Deserialize, PartialEq, Eq)] @@ -108,6 +126,9 @@ pub(super) struct EffectiveDefaults { /// The default profile name from `defaults.profile` (the CLI `--profile` /// still wins over this; resolution happens in the consumer). pub(super) profile: Option, + /// The default environment name from `defaults.environment` (the CLI + /// `--environment` still wins over this; resolution happens in the consumer). + pub(super) environment: Option, } impl JetpackFileConfig { @@ -124,6 +145,7 @@ impl JetpackFileConfig { secrets_inventory: defaults.secrets_inventory.clone().unwrap_or_default(), roles: defaults.roles.clone().unwrap_or_default(), profile: defaults.profile.clone(), + environment: defaults.environment.clone(), }; } match &self.local { @@ -133,6 +155,7 @@ impl JetpackFileConfig { secrets_inventory: Vec::new(), roles: local.roles.clone().into_iter().collect(), profile: None, + environment: None, }, None => EffectiveDefaults::default(), } @@ -277,6 +300,68 @@ defaults: assert_eq!(eff.profile.as_deref(), Some("london")); } + #[test] + fn defaults_environment_parses_and_surfaces_in_effective() { + let raw = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [labs/london] + secrets_inventory: [../infra-secrets/london] + environment: prod +"; + let cfg = deserialize(raw).expect("parses"); + let eff = cfg.effective(); + assert_eq!(eff.environment.as_deref(), Some("prod")); + } + + #[test] + fn environments_map_parses_with_secrets_overlays() { + let raw = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [labs/london] + secrets_inventory: [../infra-secrets/london] + environment: prod +environments: + test: + secrets_inventory: [../infra-secrets/london-test] + staging: + secrets_inventory: [] +"; + let cfg = deserialize(raw).expect("parses"); + let envs = cfg.environments.expect("environments parsed"); + assert_eq!(envs.len(), 2); + let test = envs.get("test").expect("test env"); + assert_eq!( + test.secrets_inventory, + Some(vec!["../infra-secrets/london-test".to_string()]) + ); + // staging carries an explicit empty list, distinct from None (absent). + let staging = envs.get("staging").expect("staging env"); + assert_eq!(staging.secrets_inventory, Some(Vec::new())); + } + + #[test] + fn environment_secrets_empty_vec_is_distinct_from_absent() { + // Some(vec![]) means "clear the overlay"; None means "inherit". The + // deserializer must preserve that distinction (mirrors profile behavior). + let raw = "\ +environments: + cleared: + secrets_inventory: [] + absent: {} +"; + let cfg = deserialize(raw).expect("parses"); + let envs = cfg.environments.unwrap(); + assert_eq!( + envs.get("cleared").unwrap().secrets_inventory, + Some(Vec::new()) + ); + assert_eq!(envs.get("absent").unwrap().secrets_inventory, None); + } + #[test] fn profiles_map_parses() { let raw = "\ From 658ba90187e69cb6c61d9d8509de3e3bf6967bb3 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Thu, 25 Jun 2026 22:49:40 +0100 Subject: [PATCH 2/3] feat(iaci): --environment selects an env-scoped secrets overlay (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selection + application half of the environment axis, orthogonal to profile: - CLI: `--environment` / `-E` flag (fields `environment`/`active_environment`, mirroring `--profile`), surfaced in the verbose resolution summary. - Application: a selected environment APPENDS its `secrets_inventory` to the load list (loaded last → later-wins) on top of whatever profile/defaults selected. Precedence is CLI `--environment` > `defaults.environment`. It affects secrets only, so `--no-secrets` skips it entirely; an unknown environment is a clear error. Six tests cover: overlay-appended-last, `-E` alias, default-env, orthogonality with `--profile`, unknown-env error, and `--no-secrets` skip. Verified on the real binary: `-v` shows `sec/base` alone without `--environment`, and `sec/base, sec/envtest` + an `Environment | test` row with `-E test`. The overlay-merge (env var shadows base) relies on loading.rs's existing later-wins multi-path behavior; the templated-`play.groups` e2e from the issue depends on #52 (unmerged), so lands with that integration. Co-Authored-By: Claude --- src/cli/parser.rs | 221 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 2 deletions(-) diff --git a/src/cli/parser.rs b/src/cli/parser.rs index f39f9d4..ff06c51 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -95,6 +95,13 @@ pub struct CliParser { /// The resolved profile name (CLI `--profile` or `defaults.profile`) after /// `apply_file_defaults` — for the resolution summary. pub active_profile: Option, + /// `--environment NAME`: selects a named env overlay from the contract's + /// `environments:` map (appends its secrets_inventory on top of whatever a + /// profile selected). When unset, `defaults.environment` is used. + pub environment: Option, + /// The resolved environment name (CLI `--environment` or + /// `defaults.environment`) after `apply_file_defaults` — for the summary. + pub active_environment: Option, /// `automation.source` from the contract, surfaced in the summary. /// **Informational only** — Jetpack does not fetch it yet. pub automation_source: Option, @@ -287,6 +294,8 @@ pub enum Arguments { ARGUMENT_CHECK, ARGUMENT_NO_SECRETS, ARGUMENT_PROFILE, + ARGUMENT_ENVIRONMENT, + ARGUMENT_ENVIRONMENT_SHORT, } impl Arguments { @@ -331,6 +340,8 @@ impl Arguments { Arguments::ARGUMENT_CHECK => "--check", Arguments::ARGUMENT_NO_SECRETS => "--no-secrets", Arguments::ARGUMENT_PROFILE => "--profile", + Arguments::ARGUMENT_ENVIRONMENT => "--environment", + Arguments::ARGUMENT_ENVIRONMENT_SHORT => "-E", } } } @@ -383,6 +394,8 @@ fn build_argument_map() -> HashMap { (Arguments::ARGUMENT_CHECK, "--check"), (Arguments::ARGUMENT_NO_SECRETS, "--no-secrets"), (Arguments::ARGUMENT_PROFILE, "--profile"), + (Arguments::ARGUMENT_ENVIRONMENT, "--environment"), + (Arguments::ARGUMENT_ENVIRONMENT_SHORT, "-E"), ]; let mut map: HashMap = HashMap::new(); for (e, i) in inputs.iter() { @@ -591,6 +604,8 @@ impl CliParser { config_path: None, profile: None, active_profile: None, + environment: None, + active_environment: None, automation_source: None, } } @@ -718,6 +733,10 @@ impl CliParser { Arguments::ARGUMENT_PROFILE => { self.store_profile(&args[arg_count]) } + Arguments::ARGUMENT_ENVIRONMENT + | Arguments::ARGUMENT_ENVIRONMENT_SHORT => { + self.store_environment(&args[arg_count]) + } Arguments::ARGUMENT_INVENTORY => { self.append_inventory(&args[arg_count]) } @@ -1137,6 +1156,11 @@ impl CliParser { Ok(()) } + fn store_environment(&mut self, value: &str) -> Result<(), String> { + self.environment = Some(value.to_string()); + Ok(()) + } + fn increase_verbosity(&mut self, amount: u32) -> Result<(), String> { self.verbosity += amount; Ok(()) @@ -1215,9 +1239,32 @@ impl CliParser { } } - // Surface the resolved profile + automation.source for the summary. - // automation.source is informational only — not fetched. + // Environment resolution (precedence: CLI --environment > + // defaults.environment). Orthogonal to profile: an environment layers an + // env-scoped secrets overlay ON TOP of whatever profile/defaults selected + // — its `secrets_inventory` is appended (loaded last → later-wins). It + // affects secrets only, so `--no-secrets` skips the whole step (no error, + // no append); without `--no-secrets` an unknown environment is a clear + // error, since the operator asked for an overlay we can't find. + let environment_name = self.environment.clone().or(defaults.environment.clone()); + // The environment affects secrets only, so `--no-secrets` skips the + // overlay entirely (folded into the Option to avoid a nested boolean + // `if`); an unknown environment is otherwise a clear error. + if let Some(name) = environment_name.as_ref().filter(|_| !self.no_secrets) { + let env = config + .environments + .as_ref() + .and_then(|e| e.get(name)) + .ok_or_else(|| format!("environment '{}' not found in .jetpack.yml", name))?; + if let Some(secrets) = &env.secrets_inventory { + defaults.secrets_inventory.extend(secrets.iter().cloned()); + } + } + + // Surface the resolved profile/environment + automation.source for the + // summary. automation.source is informational only — not fetched. self.active_profile = profile_name; + self.active_environment = environment_name; self.automation_source = config.automation.as_ref().and_then(|a| a.source.clone()); // Each of playbook/inventory/roles is filled ONLY when the CLI left it @@ -1350,6 +1397,9 @@ impl CliParser { if let Some(profile) = &self.active_profile { rows.push((String::from("Profile"), profile.clone())); } + if let Some(environment) = &self.active_environment { + rows.push((String::from("Environment"), environment.clone())); + } rows.push((String::from("Roles"), join_paths(&self.role_paths))); if let Some(source) = &self.automation_source { // Informational: parsed but not yet fetched. @@ -2696,6 +2746,7 @@ mod tests { "sec/default", "sec/perth", "sec/london", + "sec/envtest", ] { fs::create_dir_all(root.join(d)).unwrap(); } @@ -2720,6 +2771,14 @@ mod tests { .map(|p| p.to_string_lossy().to_string()) } + fn has_segment(paths: &Arc>>, suffix: &str) -> bool { + paths + .read() + .unwrap() + .iter() + .any(|p| p.to_string_lossy().ends_with(suffix)) + } + #[test] fn profile_overrides_inventory_and_secrets() { let contract = "\ @@ -2840,6 +2899,164 @@ profiles: ); } + #[test] + fn environment_appends_secrets_overlay_last() { + // The env overlay is appended after the base secrets (loaded last → + // later-wins), so both are present and the env one is last. + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] + secrets_inventory: [sec/default] +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (parser, result) = parse_with_contract(contract, &["apply", "--environment", "test"]); + assert!(result.is_ok(), "{:?}", result.err()); + assert!( + has_segment(&parser.secrets_paths, "sec/default"), + "base secrets still present under an environment" + ); + assert!( + last_segment(&parser.secrets_paths) + .unwrap() + .ends_with("sec/envtest"), + "environment overlay appended last (later-wins)" + ); + assert_eq!(parser.active_environment.as_deref(), Some("test")); + } + + #[test] + fn environment_short_alias_works() { + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] + secrets_inventory: [sec/default] +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (parser, result) = parse_with_contract(contract, &["apply", "-E", "test"]); + assert!(result.is_ok(), "{:?}", result.err()); + assert!( + last_segment(&parser.secrets_paths) + .unwrap() + .ends_with("sec/envtest"), + "-E selects the environment like --environment" + ); + } + + #[test] + fn defaults_environment_used_when_no_flag() { + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] + secrets_inventory: [sec/default] + environment: test +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (parser, result) = parse_with_contract(contract, &["apply"]); + assert!(result.is_ok(), "{:?}", result.err()); + assert!( + last_segment(&parser.secrets_paths) + .unwrap() + .ends_with("sec/envtest"), + "defaults.environment activates the overlay without --environment" + ); + } + + #[test] + fn environment_orthogonal_to_profile() { + // --profile selects inventory+secrets; --environment layers its overlay + // on top. Both the profile's secrets and the env overlay load. + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] + secrets_inventory: [sec/default] +profiles: + london: + inventory: [inv/london] + secrets_inventory: [sec/london] +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (parser, result) = parse_with_contract( + contract, + &["apply", "--profile", "london", "--environment", "test"], + ); + assert!(result.is_ok(), "{:?}", result.err()); + assert!( + last_segment(&parser.inventory_paths) + .unwrap() + .ends_with("inv/london"), + "profile still drives inventory" + ); + assert!( + has_segment(&parser.secrets_paths, "sec/london"), + "profile secrets present" + ); + assert!( + last_segment(&parser.secrets_paths) + .unwrap() + .ends_with("sec/envtest"), + "environment overlay layered on top of the profile secrets" + ); + } + + #[test] + fn unknown_environment_errors_clearly() { + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (_parser, result) = parse_with_contract(contract, &["apply", "--environment", "bogus"]); + let err = result.unwrap_err(); + assert!(err.contains("environment 'bogus'"), "{err}"); + assert!(err.contains("not found"), "{err}"); + } + + #[test] + fn no_secrets_skips_the_environment_overlay() { + // --no-secrets skips the entire secrets machinery, including the env + // overlay — so an environment that would append is a no-op (and an + // unknown environment under --no-secrets does not error). + let contract = "\ +version: 1 +defaults: + playbook: pb/install.yml + inventory: [inv/default] + secrets_inventory: [sec/default] +environments: + test: + secrets_inventory: [sec/envtest] +"; + let (parser, result) = parse_with_contract( + contract, + &["apply", "--environment", "test", "--no-secrets"], + ); + assert!(result.is_ok(), "{:?}", result.err()); + assert!( + parser.secrets_paths.read().unwrap().is_empty(), + "--no-secrets skips the environment overlay too" + ); + } + #[test] fn cli_inventory_flag_beats_profile() { // CLI -i wins over the profile's inventory (precedence: CLI > profile). From 14073ff4a4b97a9ca98e01f03fff5ad2ad8f0d85 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Thu, 25 Jun 2026 23:25:37 +0100 Subject: [PATCH 3/3] =?UTF-8?q?test(playbooks):=20environment-axis=20?= =?UTF-8?q?=C3=97=20templated-groups=20composition=20e2e=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stitches the real layers the runtime chains — `load_inventory` → `resolve_target_groups` → `get_play_hosts` — with no mocks: an environment's `secrets_inventory` overlay (loaded last → later-wins) pins `target` in `group_vars/all`, where templated `play.groups` reads it, so one playbook fans out to the env-specific cluster. A causality test confirms the var genuinely comes from the overlay (Strict templating errors without it). This owns the previously- untested seam between the environment axis (#60) and templated groups (#52); the `--environment`→append step is covered by the parser's own tests. Co-Authored-By: Claude --- src/playbooks/traversal.rs | 179 +++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/playbooks/traversal.rs b/src/playbooks/traversal.rs index e7ee1f6..4dd5428 100644 --- a/src/playbooks/traversal.rs +++ b/src/playbooks/traversal.rs @@ -2160,3 +2160,182 @@ mod target_groups_tests { assert_eq!(names, vec!["webservers-host".to_string()]); } } + +#[cfg(test)] +mod env_axis_groups_composition_tests { + //! End-to-end composition of the **environment axis** (#60) with templated + //! `play.groups` (#52). An environment's `secrets_inventory` overlay is + //! loaded last → later-wins, so a var it pins (e.g. `target`) lands in + //! `group_vars/all`, exactly where `resolve_target_groups` reads it to + //! template the targeted group. One playbook then fans out to the + //! environment-specific cluster with no per-env playbook duplication. + //! + //! This stitches the real layers the runtime chains — `load_inventory` → + //! `resolve_target_groups` → `get_play_hosts` — with no mocks and no + //! injected context vars. The load list is exactly the shape + //! `CliParser::inventory_load_paths()` yields once the selected environment + //! appends its overlay (main inventory first, env secrets last). The + //! `--environment` flag → append step is covered by the parser's own unit + //! tests; this module owns the previously-untested seam between them: that a + //! var arriving via a loaded overlay is visible to templating. + use super::*; + use crate::cli::parser::CliParser; + use crate::connection::no::NoFactory; + use crate::inventory::inventory::Inventory; + use crate::inventory::loading::load_inventory; + use crate::playbooks::context::PlaybookContext; + use crate::playbooks::visitor::{CheckMode, PlaybookVisitor}; + use std::collections::{HashMap, HashSet}; + use std::fs; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + + /// Write a topology group `name` (with `members`) under `/groups/`. + fn write_group(dir: &Path, name: &str, members: &[&str]) { + let groups = dir.join("groups"); + fs::create_dir_all(&groups).unwrap(); + let mut body = String::from("hosts:\n"); + for m in members { + body.push_str(&format!(" - {}\n", m)); + } + fs::write(groups.join(name), body).unwrap(); + } + + /// Write a vars-only overlay (the `secrets_inventory` shape — no `groups/` + /// dir) carrying `group_vars/all`. This is what an environment overlay path + /// looks like on disk. + fn write_all_vars_overlay(dir: &Path, all_yaml: &str) { + let gv = dir.join("group_vars"); + fs::create_dir_all(&gv).unwrap(); + fs::write(gv.join("all"), all_yaml).unwrap(); + } + + /// Minimal `Play` targeting the given (possibly templated) groups. + fn play_with_groups(name: &str, groups: &[&str]) -> Play { + Play { + name: name.to_string(), + groups: groups.iter().map(|g| g.to_string()).collect(), + roles: None, + defaults: None, + vars: None, + vars_files: None, + sudo: None, + sudo_template: None, + ssh_user: None, + ssh_port: None, + tasks: None, + handlers: None, + batch_size: None, + instantiate: None, + } + } + + /// Build a `RunState` around an already-loaded inventory. No vars are + /// injected into the context — the templated group name must come from the + /// inventory's merged `group_vars/all` (populated by the loaded environment + /// overlay), not from play/CLI vars. That isolates the overlay as the sole + /// var source, so the test's causality is unambiguous. + fn run_state_with_inventory(inventory: Arc>) -> Arc { + let mut parser = CliParser::new(); + parser.extra_vars = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let context = Arc::new(RwLock::new(PlaybookContext::new(&parser))); + { + let mut ctx = context.write().unwrap(); + *ctx.vars_storage.write().unwrap() = serde_yaml::Mapping::new(); + ctx.play_index = 0; + } + Arc::new(RunState { + inventory, + playbook_paths: Arc::new(RwLock::new(Vec::new())), + role_paths: Arc::new(RwLock::new(Vec::new())), + module_paths: Arc::new(RwLock::new(Vec::new())), + limit_hosts: Vec::new(), + limit_groups: Vec::new(), + batch_size: None, + context, + visitor: Arc::new(RwLock::new(PlaybookVisitor::new(CheckMode::No))), + connection_factory: Arc::new(RwLock::new(NoFactory::new())), + tags: None, + allow_localhost_delegation: false, + is_pull_mode: false, + syntax_mode: false, + play_groups: None, + output_handler: None, + async_mode: false, + playbook_contents: Vec::new(), + processed_role_tasks: Arc::new(RwLock::new(HashSet::new())), + processed_role_handlers: Arc::new(RwLock::new(HashSet::new())), + role_processing_stack: Arc::new(RwLock::new(Vec::new())), + fetched_files: Arc::new(Mutex::new(HashMap::new())), + }) + } + + #[test] + fn environment_overlay_supplies_templated_group_var() { + // On-disk world: one site inventory carrying both clusters' topology, + // plus a vars-only environment overlay pinning which cluster this run + // targets. Loaded exactly as the runtime would once `--environment test` + // appends the overlay: main first, env overlay last (later-wins). + let root = TempDir::new().unwrap(); + let main = root.path().join("inv"); + write_group(&main, "webservers", &["web1"]); + write_group(&main, "test-webservers", &["testweb1"]); + let overlay = root.path().join("sec-env"); + write_all_vars_overlay(&overlay, "target: test-webservers\n"); + + let inventory = Arc::new(RwLock::new(Inventory::new())); + let load_list: Arc>> = + Arc::new(RwLock::new(vec![main.clone(), overlay.clone()])); + load_inventory(&inventory, load_list).expect("main + env overlay load"); + + // The environment overlay's var reached group_vars/all (later-wins merge). + let all_vars = inventory + .read() + .unwrap() + .get_group("all") + .read() + .unwrap() + .get_variables(); + assert_eq!(all_vars["target"], "test-webservers"); + + // Templated play.groups resolves the env-supplied name → the + // environment-specific cluster → correct host fan-out. + let rs = run_state_with_inventory(inventory); + let play = play_with_groups("k3s", &["{{ target }}"]); + let groups = resolve_target_groups(&rs, &play) + .expect("env-overlay var resolves the templated group"); + assert_eq!(groups, vec!["test-webservers".to_string()]); + + let hosts = get_play_hosts(&rs, &groups); + let names: Vec = hosts + .iter() + .map(|h| h.read().unwrap().name.clone()) + .collect(); + assert_eq!(names, vec!["testweb1".to_string()]); + } + + #[test] + fn templated_group_fails_without_the_environment_overlay() { + // Causality check: with NO environment overlay in the load list, `target` + // is undefined and Strict templating must reject it with a clear error — + // proving the var above genuinely came from the overlay, not elsewhere. + let root = TempDir::new().unwrap(); + let main = root.path().join("inv"); + write_group(&main, "webservers", &["web1"]); + write_group(&main, "test-webservers", &["testweb1"]); + + let inventory = Arc::new(RwLock::new(Inventory::new())); + load_inventory( + &inventory, + Arc::new(RwLock::new(vec![main.clone()])), + ) + .expect("main inventory alone loads"); + + let rs = run_state_with_inventory(inventory); + let play = play_with_groups("k3s", &["{{ target }}"]); + let err = resolve_target_groups(&rs, &play) + .expect_err("undefined var without the overlay must error"); + assert!(err.contains("k3s"), "error names the play: {}", err); + assert!(err.contains("target"), "error names the token: {}", err); + } +}