diff --git a/docs/environments.md b/docs/environments.md index 36e5df4..2d46919 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -88,9 +88,9 @@ deslicer inventory validate --environment acme-prod deslicer inventory validate --environment acme-prod --log-format json ``` -Checks (fail closed): YAML shape and required `inventory_group`; no duplicate groups; no duplicate `source_path`+`dest_dir`; `source_path` exists on disk unless `state: absent`; thin `dest_dir` authoring allowlist (`apps`, `deployment-apps`, `manager-apps`, `shcluster/apps`, `peer-apps`, `slave-apps`, `users` — not `master-apps`); live host-group allowlist via auth → Observer `GET /api/v1/groups` (not a hardcoded list). +Checks (fail closed): YAML shape and required `inventory_group`; no duplicate groups; no duplicate `source_path`+`dest_dir`; `source_path` exists on disk unless `state: absent`; thin `dest_dir` authoring allowlist (`apps`, `deployment-apps`, `manager-apps`, `shcluster/apps` — not `master-apps`, `peer-apps`, `slave-apps`, or `users`); live host-group allowlist via auth → Observer `GET /api/v1/groups` (not a hardcoded list). -Role-based `dest_dir` defaults (when YAML omits `dest_dir`) and identity alias `master-apps` → `manager-apps` are owned by DAP / Observer at compile time — the CLI does not invent defaults. +Role-based `dest_dir` defaults (when YAML omits `dest_dir`) and identity alias `master-apps` → `manager-apps` are owned by DAP / Observer at compile time — the CLI does not invent defaults. `peer-apps` / `slave-apps` / `users` remain Observer observation scopes, not env YAML deploy targets. `--force` on `init` overwrites workflow templates only — it does not wipe operator `apps:` lists. diff --git a/src/environment_yaml/validate/checks.rs b/src/environment_yaml/validate/checks.rs index b643712..f1129bb 100644 --- a/src/environment_yaml/validate/checks.rs +++ b/src/environment_yaml/validate/checks.rs @@ -301,6 +301,29 @@ fn validate_common_fields(map: &serde_yml::Mapping, obj_path: &str, ctx: &mut Va )), } } + + // Phase 1b: thin authoring check for optional target_host override. + // DAP enforces that the host is a member of the destination inventory_group. + if let Some(target_host) = map.get("target_host") { + match scalar_string(Some(target_host)) { + Some(value) if !value.trim().is_empty() => {} + Some(_) => ctx.issues.push(issue( + ctx.file_label, + &format!("{obj_path}.target_host"), + Severity::Error, + "target_host must be a non-empty string".into(), + "Set target_host to a hostname that is a member of this destination's \ + inventory_group (DAP verifies membership at compile)", + )), + None => ctx.issues.push(issue( + ctx.file_label, + &format!("{obj_path}.target_host"), + Severity::Error, + "target_host must be a string".into(), + "Set target_host to a hostname string, or omit the field", + )), + } + } } fn warn_if_not_splunk_app_root(base: &str, ctx: &mut ValidationCtx<'_>) { diff --git a/src/environment_yaml/validate/mod.rs b/src/environment_yaml/validate/mod.rs index 07a2996..150ce17 100644 --- a/src/environment_yaml/validate/mod.rs +++ b/src/environment_yaml/validate/mod.rs @@ -17,21 +17,13 @@ pub const VALID_STATES: &[&str] = &["present", "absent"]; /// Thin authoring allowlist for env YAML `dest_dir` (fail-fast in GH Actions / local validate). /// -/// DAP / Observer owns the full scope set, role-based defaults when `dest_dir` is -/// omitted, and identity alias `master-apps` → `manager-apps`. Do not put that -/// logic in the CLI. +/// Deploy targets only (D4 role defaults): DS → `deployment-apps`, CM → `manager-apps`, +/// SH deployer → `shcluster/apps`, else `apps` (on-disk `etc/apps`). /// -/// `apps` maps on-disk to `etc/apps`. Legacy `master-apps` is rejected here — -/// authors must use `manager-apps`. -pub const VALID_DEST_DIRS: &[&str] = &[ - "apps", - "deployment-apps", - "manager-apps", - "shcluster/apps", - "peer-apps", - "slave-apps", - "users", -]; +/// DAP / Observer still **observes/parses** `peer-apps`, `slave-apps`, `users`, and +/// legacy `master-apps` — those are not GitOps deploy destinations for env YAML. +/// Legacy `master-apps` is rejected here; authors must use `manager-apps`. +pub const VALID_DEST_DIRS: &[&str] = &["apps", "deployment-apps", "manager-apps", "shcluster/apps"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] diff --git a/src/environment_yaml/validate/tests.rs b/src/environment_yaml/validate/tests.rs index d2bf385..f92952d 100644 --- a/src/environment_yaml/validate/tests.rs +++ b/src/environment_yaml/validate/tests.rs @@ -131,7 +131,7 @@ fn rejects_legacy_master_apps_dest_dir() { } #[test] -fn accepts_peer_apps_dest_dir() { +fn rejects_peer_apps_dest_dir() { let dir = tempdir().unwrap(); write_app(dir.path(), "apps/ta_nix"); let yaml = "destinations:\n\ @@ -139,17 +139,65 @@ fn accepts_peer_apps_dest_dir() { \x20\x20\x20\x20apps:\n\ \x20\x20\x20\x20\x20\x20- source_path: apps/ta_nix\n\ \x20\x20\x20\x20\x20\x20\x20\x20dest_dir: peer-apps\n"; + let report = + validate_environment_yaml(yaml, "prod.yml", dir.path(), Some(&known(&["indexers"]))); + assert!(report + .errors() + .any(|issue| issue.message.contains("invalid dest_dir"))); +} + +#[test] +fn rejects_slave_apps_dest_dir() { + let dir = tempdir().unwrap(); + write_app(dir.path(), "apps/ta_nix"); + let yaml = "destinations:\n\ +\x20\x20- inventory_group: indexers\n\ +\x20\x20\x20\x20apps:\n\ +\x20\x20\x20\x20\x20\x20- source_path: apps/ta_nix\n\ +\x20\x20\x20\x20\x20\x20\x20\x20dest_dir: slave-apps\n"; + let report = + validate_environment_yaml(yaml, "prod.yml", dir.path(), Some(&known(&["indexers"]))); + assert!(report + .errors() + .any(|issue| issue.message.contains("invalid dest_dir"))); +} + +#[test] +fn accepts_nonempty_target_host() { + let dir = tempdir().unwrap(); + write_app(dir.path(), "apps/ta_nix"); + let yaml = "destinations:\n\ +\x20\x20- inventory_group: indexers\n\ +\x20\x20\x20\x20apps:\n\ +\x20\x20\x20\x20\x20\x20- source_path: apps/ta_nix\n\ +\x20\x20\x20\x20\x20\x20\x20\x20target_host: splunk-idx-01.example.com\n"; let report = validate_environment_yaml(yaml, "prod.yml", dir.path(), Some(&known(&["indexers"]))); assert!( report .errors() - .all(|issue| !issue.message.contains("dest_dir")), + .all(|issue| !issue.message.contains("target_host")), "{:?}", report.issues ); } +#[test] +fn rejects_empty_target_host() { + let dir = tempdir().unwrap(); + write_app(dir.path(), "apps/ta_nix"); + let yaml = "destinations:\n\ +\x20\x20- inventory_group: indexers\n\ +\x20\x20\x20\x20apps:\n\ +\x20\x20\x20\x20\x20\x20- source_path: apps/ta_nix\n\ +\x20\x20\x20\x20\x20\x20\x20\x20target_host: ' '\n"; + let report = + validate_environment_yaml(yaml, "prod.yml", dir.path(), Some(&known(&["indexers"]))); + assert!(report.errors().any(|issue| issue + .message + .contains("target_host must be a non-empty string"))); +} + #[test] fn rejects_unknown_live_group() { let dir = tempdir().unwrap();