From d42955db9045229158a29e07b8d333314fc5b9a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 08:36:49 +0000 Subject: [PATCH 1/2] fix(init): emit apps: [] so scaffolds pass inventory validate github-token init wrote a null apps key, which inventory validate rejects as "apps must be a list". New groups and null blocks are now an empty list; existing app entries stay intact. Co-authored-by: Daniel Young --- docs/environments.md | 2 +- src/environment_yaml/merge.rs | 13 +++++++++---- src/environment_yaml/mod.rs | 29 ++++++++++++++++++++++++++--- 3 files changed, 36 insertions(+), 8 deletions(-) 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/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()); From 9a4c59e8b5c9a9312a33b14226f085a65f3603d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 08:36:49 +0000 Subject: [PATCH 2/2] fix(cli): add help text for flags with empty descriptions Document the 23 options reported on groups, inventory, init, catalog, license, remediate, upgrade, and enroll create, including units and defaults for enrollment limits. Co-authored-by: Daniel Young --- src/commands/catalog/list.rs | 1 + src/commands/catalog/plan.rs | 1 + src/commands/catalog/show.rs | 1 + src/commands/enroll/create.rs | 3 ++ src/commands/groups/list.rs | 1 + src/commands/init/mod.rs | 1 + src/commands/inventory/apps.rs | 1 + src/commands/inventory/import_seed.rs | 1 + src/commands/inventory/list.rs | 1 + src/commands/inventory/sync.rs | 1 + src/commands/license/bind.rs | 1 + src/commands/license/status.rs | 1 + src/commands/remediate/analyse.rs | 4 ++ src/commands/remediate/draft.rs | 2 + src/commands/upgrade/mod.rs | 1 + src/commands/upgrade/readiness.rs | 1 + tests/cli_help.rs | 73 +++++++++++++++++++++++++++ 17 files changed, 95 insertions(+) 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/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}"); +}