diff --git a/docs/environments.md b/docs/environments.md index 1fbdd22..511eeaf 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -62,7 +62,7 @@ destinations: apps: - source_path: apps/ta_nix - inventory_group: forwarders - apps: + apps: [] ``` Each `inventory_group` is an Observer host group (`GET /api/v1/groups`). Apps are repo-relative `source_path` entries. The filename stem (`acme-prod` for `acme-prod.yml`) is the `--environment` value and the GitHub Environment name. A repo-level `DESLICER_ENVIRONMENT` variable is only the name pointer so `pull_request` can select that Environment. diff --git a/src/commands/catalog/list.rs b/src/commands/catalog/list.rs index 35c8441..3eb4bce 100644 --- a/src/commands/catalog/list.rs +++ b/src/commands/catalog/list.rs @@ -7,6 +7,7 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, } diff --git a/src/commands/catalog/plan.rs b/src/commands/catalog/plan.rs index 09ed14f..8945f23 100644 --- a/src/commands/catalog/plan.rs +++ b/src/commands/catalog/plan.rs @@ -19,6 +19,7 @@ pub struct Args { #[arg(long)] pub name: Option, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/catalog/show.rs b/src/commands/catalog/show.rs index 2b6f39f..cb59545 100644 --- a/src/commands/catalog/show.rs +++ b/src/commands/catalog/show.rs @@ -9,6 +9,7 @@ pub struct Args { /// Catalog blueprint id (from `catalog list`) pub id: String, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, } diff --git a/src/commands/enroll/create.rs b/src/commands/enroll/create.rs index 0bf92fb..3db148b 100644 --- a/src/commands/enroll/create.rs +++ b/src/commands/enroll/create.rs @@ -18,12 +18,15 @@ pub struct Args { #[arg(long, value_parser = ["insights", "bootstrap"])] pub purpose: String, + /// Label stored with the enrollment token. #[arg(long)] pub name: Option, + /// Maximum hosts this token can enroll (1-10000). Default 500. #[arg(long)] pub max_hosts: Option, + /// Token lifetime in days (1-365). Default 30. #[arg(long)] pub expires_days: Option, diff --git a/src/commands/groups/list.rs b/src/commands/groups/list.rs index 3bc622a..c2f8c43 100644 --- a/src/commands/groups/list.rs +++ b/src/commands/groups/list.rs @@ -8,6 +8,7 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index d04c524..89c24cb 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -43,6 +43,7 @@ pub struct Args { #[arg(long, default_value = "auto")] pub provider: String, + /// Tenant slug; resolves to the environment file stem and GitHub Environment name. #[arg(long)] pub environment: Option, diff --git a/src/commands/inventory/apps.rs b/src/commands/inventory/apps.rs index 637649d..8a4384d 100644 --- a/src/commands/inventory/apps.rs +++ b/src/commands/inventory/apps.rs @@ -21,6 +21,7 @@ pub struct Args { #[arg(long)] pub group: Option, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/inventory/import_seed.rs b/src/commands/inventory/import_seed.rs index ff32615..31a9c8d 100644 --- a/src/commands/inventory/import_seed.rs +++ b/src/commands/inventory/import_seed.rs @@ -18,6 +18,7 @@ pub struct Args { #[arg(long, value_name = "FILE")] pub file: PathBuf, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/inventory/list.rs b/src/commands/inventory/list.rs index d89e7ff..4b434c3 100644 --- a/src/commands/inventory/list.rs +++ b/src/commands/inventory/list.rs @@ -7,6 +7,7 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, } diff --git a/src/commands/inventory/sync.rs b/src/commands/inventory/sync.rs index 8687091..f941da4 100644 --- a/src/commands/inventory/sync.rs +++ b/src/commands/inventory/sync.rs @@ -17,6 +17,7 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Environment filename stem (GitHub Environment / tenant slug). #[arg(long)] pub environment: Option, diff --git a/src/commands/license/bind.rs b/src/commands/license/bind.rs index 55b62a9..393dc2e 100644 --- a/src/commands/license/bind.rs +++ b/src/commands/license/bind.rs @@ -11,6 +11,7 @@ pub struct Args { #[arg(long)] pub key: String, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/license/status.rs b/src/commands/license/status.rs index 4ecf223..cbaed9e 100644 --- a/src/commands/license/status.rs +++ b/src/commands/license/status.rs @@ -7,6 +7,7 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, } diff --git a/src/commands/remediate/analyse.rs b/src/commands/remediate/analyse.rs index c7883a3..08a488a 100644 --- a/src/commands/remediate/analyse.rs +++ b/src/commands/remediate/analyse.rs @@ -6,15 +6,19 @@ use crate::Ctx; #[derive(ClapArgs)] pub struct Args { + /// Host to analyse (hostname or host UUID). #[arg(long)] pub host: Option, + /// App name to include in the analysis. #[arg(long)] pub app: Option, + /// Inventory group to analyse instead of one host. #[arg(long)] pub group: Option, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, } diff --git a/src/commands/remediate/draft.rs b/src/commands/remediate/draft.rs index d51a9c6..f852ee9 100644 --- a/src/commands/remediate/draft.rs +++ b/src/commands/remediate/draft.rs @@ -20,9 +20,11 @@ pub struct Args { #[arg(long, required_unless_present = "host")] pub group: Option, + /// Optional name for the draft remediation plan. #[arg(long)] pub name: Option, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/upgrade/mod.rs b/src/commands/upgrade/mod.rs index 25bc29d..703f6e1 100644 --- a/src/commands/upgrade/mod.rs +++ b/src/commands/upgrade/mod.rs @@ -49,6 +49,7 @@ pub struct RollingArgs { #[arg(long = "role-group", num_args = 1, value_parser = clap::builder::PossibleValuesParser::new(UPGRADE_ROLE_GROUPS))] pub role_groups: Vec, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/commands/upgrade/readiness.rs b/src/commands/upgrade/readiness.rs index d2eb56c..d29fe37 100644 --- a/src/commands/upgrade/readiness.rs +++ b/src/commands/upgrade/readiness.rs @@ -13,6 +13,7 @@ pub struct Args { #[arg(long, required = true)] pub target: String, + /// Workspace environment (tenant slug) used to scope Observer auth. #[arg(long)] pub environment: Option, diff --git a/src/environment_yaml/merge.rs b/src/environment_yaml/merge.rs index c9a26d3..a212040 100644 --- a/src/environment_yaml/merge.rs +++ b/src/environment_yaml/merge.rs @@ -39,8 +39,8 @@ pub fn generate_environment_yaml( /// Merge Observer groups with an existing file. /// -/// * New groups are appended with an empty `apps:` list. -/// * Existing `apps:` blocks for surviving groups are preserved. +/// * New groups, and groups whose `apps:` value is null, are written as `apps: []`. +/// * Existing `apps:` blocks that list entries or an inline value are preserved. /// * Removed groups with no `source_path` apps are dropped. /// * Removed groups that still list apps are kept and reported as blocked. pub fn merge_environment_yaml( @@ -163,10 +163,15 @@ fn emit_blocked_groups( fn emit_destination(lines: &mut Vec, name: &str, existing: Option<&ExistingAppsBlock>) { lines.push(format!(" - inventory_group: {}", format_scalar(name))); match existing { - Some(block) => { + Some(block) if apps_block_has_content(block) => { lines.push(format!(" apps:{}", block.inline_suffix)); lines.extend(block.body_lines.iter().cloned()); } - None => lines.push(" apps:".to_string()), + // A bare `apps:` is YAML null. `inventory validate` requires a list. + Some(_) | None => lines.push(" apps: []".to_string()), } } + +fn apps_block_has_content(block: &ExistingAppsBlock) -> bool { + !block.inline_suffix.trim().is_empty() || !block.body_lines.is_empty() +} diff --git a/src/environment_yaml/mod.rs b/src/environment_yaml/mod.rs index 8b1d22b..7d625c9 100644 --- a/src/environment_yaml/mod.rs +++ b/src/environment_yaml/mod.rs @@ -20,6 +20,9 @@ pub use validate::{ #[cfg(test)] mod tests { + use std::collections::HashSet; + use std::path::Path; + use super::*; fn groups(names: &[&str]) -> Vec { @@ -63,10 +66,30 @@ mod tests { \n\ destinations:\n\ \x20\x20- inventory_group: indexers\n\ -\x20\x20\x20\x20apps:\n"; +\x20\x20\x20\x20apps: []\n"; assert_eq!(result.content, expected); } + #[test] + fn generated_scaffold_passes_environment_validator() { + let result = generate_environment_yaml("dap-102", "DAP 102", &groups(&["Workers"])); + assert!( + result + .content + .contains("inventory_group: Workers\n apps: []\n"), + "{}", + result.content + ); + let known: HashSet = ["Workers".to_string()].into_iter().collect(); + let report = + validate_environment_yaml(&result.content, &result.path, Path::new("."), Some(&known)); + assert!( + report.is_ok(), + "scaffold must pass inventory validate: {:?}", + report.issues + ); + } + #[test] fn dedupes_repeated_names_and_preserves_order() { let result = generate_environment_yaml( @@ -124,10 +147,10 @@ destinations:\n\ )); assert!(result .content - .contains(" - inventory_group: forwarders\n apps:\n")); + .contains(" - inventory_group: forwarders\n apps: []\n")); assert!(result .content - .contains(" - inventory_group: search_heads\n apps:\n")); + .contains(" - inventory_group: search_heads\n apps: []\n")); assert_eq!(result.added, vec!["search_heads".to_string()]); assert!(result.removed_empty.is_empty()); assert!(result.blocked.is_empty()); diff --git a/tests/cli_help.rs b/tests/cli_help.rs index 74cbb6a..e3a2af6 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -78,3 +78,76 @@ fn change_status_help_documents_timeout() { ); assert!(help.contains("Examples:")); } + +/// Flags called out as blank in the help sweep. Keep this list aligned with that set. +const DOCUMENTED_FLAGS: &[(&[&str], &str)] = &[ + (&["groups", "list"], "environment"), + (&["inventory", "list"], "environment"), + (&["inventory", "apps"], "environment"), + (&["inventory", "sync"], "environment"), + (&["inventory", "import-seed"], "environment"), + (&["init"], "environment"), + (&["catalog", "list"], "environment"), + (&["catalog", "show"], "environment"), + (&["catalog", "plan"], "environment"), + (&["license", "status"], "environment"), + (&["license", "bind"], "environment"), + (&["remediate", "analyse"], "environment"), + (&["remediate", "draft"], "environment"), + (&["upgrade", "plan"], "environment"), + (&["upgrade", "preview"], "environment"), + (&["upgrade", "readiness"], "environment"), + (&["enroll", "create"], "name"), + (&["enroll", "create"], "max-hosts"), + (&["enroll", "create"], "expires-days"), + (&["remediate", "analyse"], "host"), + (&["remediate", "analyse"], "app"), + (&["remediate", "analyse"], "group"), + (&["remediate", "draft"], "name"), +]; + +fn long_flag_help(path: &[&str], flag: &str) -> String { + let mut cmd = Cli::command(); + let mut current = &mut cmd; + for name in path { + current = current + .find_subcommand_mut(name) + .unwrap_or_else(|| panic!("missing subcommand {name} in {}", path.join(" "))); + } + let argument = current + .get_arguments() + .find(|arg| arg.get_long() == Some(flag)) + .unwrap_or_else(|| panic!("missing --{flag} on {}", path.join(" "))); + argument + .get_help() + .or_else(|| argument.get_long_help()) + .map(|text| text.to_string()) + .unwrap_or_default() +} + +#[test] +fn previously_blank_flags_have_one_line_help() { + assert_eq!(DOCUMENTED_FLAGS.len(), 23); + for (path, flag) in DOCUMENTED_FLAGS { + let help = long_flag_help(path, flag); + let label = format!("{} --{flag}", path.join(" ")); + assert!(!help.trim().is_empty(), "empty help for `{label}`"); + assert!( + !help.contains('\n'), + "help for `{label}` should be one line: {help}" + ); + if *flag == "environment" { + assert!( + help.to_ascii_lowercase().contains("tenant slug"), + "`{label}` should say what --environment resolves to: {help}" + ); + } + } + + let hosts = long_flag_help(&["enroll", "create"], "max-hosts"); + assert!(hosts.contains("500"), "{hosts}"); + assert!(hosts.to_ascii_lowercase().contains("host"), "{hosts}"); + let days = long_flag_help(&["enroll", "create"], "expires-days"); + assert!(days.contains("30"), "{days}"); + assert!(days.to_ascii_lowercase().contains("day"), "{days}"); +}