From 5f449d1e38f835aa103535272c2a899758e31b80 Mon Sep 17 00:00:00 2001 From: Marco Stadler Date: Wed, 28 Oct 2026 13:26:56 +0100 Subject: [PATCH 1/3] Add environment cleanup, require init provider, and check AWS region and config keys for #67. - spa environment cleanup lists registered environments whose directory is missing and removes only their registry entries after --yes. No directories are deleted. - spa init and spa environment init require --provider unless a default is saved with spa environment set --provider. - terraform.aws rejects unknown keys. spa aws in an environment uses terraform.aws.region before AWS environment variables. - Skills pass --region on every AMI, survey, and describe lookup, and the create-config skill asks for location, name, and a summary before init. - Test coverage for the schema, region precedence, skill AWS lines, cleanup, and init provider rules. Co-authored-by: Cursor --- .agent/workflows/deploy-aws-terraform.md | 2 + AGENTS.md | 10 +- CHANGELOG.md | 11 + ROADMAP.md | 3 +- ansible/plugins/inventory/schema.py | 2 +- docs/commands.md | 27 ++- docs/contributing.md | 1 - docs/user-guide.md | 4 +- lib/spa/agent.py | 22 +- lib/spa/api.py | 42 +++- lib/spa/aws.py | 42 +++- lib/spa/cli.py | 79 +++++- lib/spa/confirm.py | 1 + lib/spa/init.py | 43 ++++ lib/spa/registry.py | 42 +++- lib/spa/usage.py | 3 +- lib/spa/validate.py | 15 +- skills/spa/README.md | 6 +- skills/spa/spa-add-test-scenario/SKILL.md | 204 ---------------- skills/spa/spa-apps/SKILL.md | 4 + skills/spa/spa-create-config/SKILL.md | 85 +++++-- .../assets/config-header-template.md | 32 ++- .../references/aws-os-matrix.md | 2 +- .../references/aws-without-credentials.md | 10 +- .../config-header-best-practices.md | 44 ++++ .../references/secrets-handling.md | 2 +- .../references/validation.md | 2 +- .../references/workflow-decision-tree.md | 26 ++ .../spa/spa-create-config/scripts/README.md | 4 +- skills/spa/spa/SKILL.md | 40 ++- skills/spa/spa/references/lifecycle.md | 11 + skills/spa/spa/references/prerequisites.md | 17 ++ tests/README.md | 9 + tests/spa_testutil.py | 3 + tests/test_docs.py | 229 ++++++++++++++++++ tests/test_schema.py | 22 ++ tests/test_spa_api.py | 4 +- tests/test_spa_cli.py | 52 +++- tests/test_spa_cli_contract.py | 2 +- tests/test_spa_environment.py | 133 ++++++++-- tests/test_spa_init.py | 75 ++++++ tests/test_spa_install.py | 2 + tests/test_splunk_config_aws.py | 35 +++ 43 files changed, 1064 insertions(+), 340 deletions(-) delete mode 100644 skills/spa/spa-add-test-scenario/SKILL.md create mode 100644 skills/spa/spa-create-config/references/config-header-best-practices.md create mode 100644 skills/spa/spa-create-config/references/workflow-decision-tree.md create mode 100644 skills/spa/spa/references/lifecycle.md create mode 100644 skills/spa/spa/references/prerequisites.md diff --git a/.agent/workflows/deploy-aws-terraform.md b/.agent/workflows/deploy-aws-terraform.md index cb0fa12b..dcecd3ca 100644 --- a/.agent/workflows/deploy-aws-terraform.md +++ b/.agent/workflows/deploy-aws-terraform.md @@ -27,6 +27,8 @@ This generates `terraform/aws/terraform.tfvars`, runs `terraform init` / `plan`, ## Step 3: Provision Infrastructure +Ask in chat and wait. Name the env and that provision creates the hosts. The request in the same message is not the yes. After that confirmation: + ```bash spa provision --yes ``` diff --git a/AGENTS.md b/AGENTS.md index 3cd189cd..73b20496 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ SPA provisions and deploys Splunk Enterprise (AWS Linux + Terraform, or VirtualB - **`SPA_HOME`**: framework (prefix or clone). **`SPA_ENV_DIR`**: one environment. Operator `spa` needs `spa init` first. Path diagram: [user guide](docs/user-guide.md#understand-the-paths). - Call **`bin/spa`** only (`spa agent schema`, `spa --agent config search QUERY`, `spa --agent apps`). Load **`/spa`** first ([skills/spa/spa/SKILL.md](skills/spa/spa/SKILL.md)). Do not import `spa.api`. Do not start a daemon. Flag catalog: [docs/commands.md](docs/commands.md). +- Do not hand-edit `splunk_config.yml`. Change it with `spa config ...` (skill `/spa-create-config`) and add apps with `spa apps ...` (skill `/spa-apps`). - Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Never answer `Proceed?` interactively. - Never print secrets (Splunkbase, AWS keys, vault). Report env vars as **set / not set**. YAML: `lookup('env', ...)`. Do not pass `-v` on provision/deploy/run (unredacted Ansible stdout). - Full command names (`spa hosts ssh`, `spa validate`), not `spa sh` / `spa val`. @@ -17,14 +18,14 @@ Human loop (same as README): [docs/user-guide.md](docs/user-guide.md). | Task | Command / path | | --- | --- | -| Config | `$SPA_ENV_DIR/config/splunk_config.yml` via `spa init --example` | +| Config | `$SPA_ENV_DIR/config/splunk_config.yml` via `spa init --example TOPOLOGY --provider aws|virtualbox|external NAME` | | Schema vs catalog | Pydantic (`spa validate`); `spa config search` then `spa config explain PATH` | | New config key | [docs/contributing.md](docs/contributing.md#adding-a-configuration-feature) | | All settings | [docs/config.md](docs/config.md) (`spa --no-agent config schema --markdown`) | | CLI flags | [docs/commands.md](docs/commands.md) (`spa --no-agent agent schema --markdown`) | | Guided design | [docs/user-guide.md](docs/user-guide.md#configure-an-environment) · `spa config search` then `spa config explain` | | 2.x → 3.0 | [docs/migrate.md](docs/migrate.md) | -| New env | `spa environment init --example cm_2idxc_sh NAME` then `spa --env NAME …`, `spa environment set --default NAME`, or `cd` into it | +| New env | `spa environment init --example cm_2idxc_sh --provider aws NAME` then `spa --env NAME …`, `spa environment set --default NAME`, or `cd` into it | | Features / apps | `spa --agent config search QUERY`; `spa --agent apps search QUERY` then `snippet APP_ID`. For 5391: `spa --agent apps content-packs list 5391`. AWS live values: `spa aws --json` | | Python | `spa venv create --shared --yes` (`spa init` creates it when missing) | | Repair Python venv | `spa venv reinstall --shared --yes` (or `spa venv rebuild --shared --yes`) | @@ -52,12 +53,11 @@ Canonical: `skills/spa/`. When to load: | Skill | When | | --- | --- | -| [spa](skills/spa/spa/SKILL.md) (`/spa`) | Load first: Software, env lifecycle, operator loop | +| [spa](skills/spa/spa/SKILL.md) (`/spa`) | Load first: Splunk lab or environment request (any topology, OS, or CPU count), Splunk deploy to existing hosts, Software, env lifecycle, operator loop. Use spa, not the AWS CLI or Terraform. | | [spa-create-config](skills/spa/spa-create-config/SKILL.md) | `splunk_config.yml` in an existing env | | [spa-apps](skills/spa/spa-apps/SKILL.md) | Splunkbase search / snippet / download | -| [spa-add-test-scenario](skills/spa/spa-add-test-scenario/SKILL.md) | App-scope tests | -Cursor: `/spa`, `/spa-create-config`, `/spa-apps`, `/spa-add-test-scenario` after `spa skills install`. See [docs/contributing.md](docs/contributing.md#agent-skills). +Cursor: `/spa`, `/spa-create-config`, `/spa-apps` after `spa skills install`. See [docs/contributing.md](docs/contributing.md#agent-skills). App-scope scenarios are in [tests/README.md](tests/README.md). Backend vs GUI: [docs/architecture.md](docs/architecture.md#controller-architecture). diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a418e3..916459ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). Work on this branch is **3.0** (M1–M3 plus the follow-on issues below). Linux packages and Homebrew are later, not part of 3.0. +### Changed + +- **terraform.aws rejects unknown keys**: a misspelled key such as `ami` next to `ami_id` now fails validation instead of being ignored. +- **AWS discovery uses the environment region**: `spa aws` without `--region` searches `terraform.aws.region` from the environment config before `AWS_REGION`, `AWS_DEFAULT_REGION`, or `~/.aws/config`. The skills pass `--region` on every AMI and survey lookup. + +- **Environment cleanup**: `spa environment cleanup` lists the registered environments whose directory is missing and removes only their registry entries after a yes (`--yes`, or a prompt on a terminal). It deletes no directories. With `--yes` it prints each entry it removed. A removed default moves to the first remaining environment. + +- **Init needs a provider**: `spa init` and `spa environment init` require `--provider aws|virtualbox|external` unless a default is saved with `spa environment set --provider NAME`. With neither, init stops before it writes anything and names the three choices. Saving `aws` as the default now writes `providers.yml` (it used to delete the file), so an explicit `aws` counts as a choice. In agent mode, a failed init returns `ok` false and `error` only, with no `data`. + ### Added +- **Skills pack** ([#67](https://github.com/splunk/splunk-platform-automator/issues/67)): three operator skills (`spa`, `spa-create-config`, `spa-apps`). Agents ask in chat and wait before provision, deploy, destroy, suspend, resume, and environment remove. `spa validate --splunk-config aws` checks that provider from the environment config. App-scope scenario steps live in `tests/README.md`. + - **External register addresses**: `spa hosts register` takes `--ssh-host` and `--ssh-port` for the SSH connection, and optional `--ip-addr` for the address other Splunk hosts use. The SSH host, port, user, key, and any `--ip-addr` are written only in `inventory/hosts`. Omit `--ip-addr` to use the SSH host for both. Omit `--ssh-port` to use port 22. The host link page keeps the public DNS name or the SSH host in the URL. - **Agent results** ([#67](https://github.com/splunk/splunk-platform-automator/issues/67)): `spa licenses` and `spa aws --validate` return a short JSON result when an agent is detected, and the full objects with `--details`. `spa aws --validate` warns when a named security group allows `0.0.0.0/0` or `::/0` to ports 22, 80, 8000, 8088, or 8089. An agent run of `provision`, `deploy`, `destroy`, and `run` prints one plain sentence on stderr when the phase or host changes, and the heartbeat writes nothing. A host is named once. Extracting the Splunk archive is its own sentence. Host preparation says it is already done when that phase changes nothing. A role says it is already configured only after that role's own tasks finish with nothing changed; the opening checks do not count. A role play with no matching hosts is silent. Provision, deploy, and destroy share one lock. While one is running, the others are refused. The lock ends when that process ends, including when it is aborted. `spa logs --follow` stops on Ctrl+C without a traceback. `Planning the infrastructure.` covers the plan, and `HOST is up.` follows the instance readiness result. The Terraform plan summary is not a progress JSON line. `splunk_download` says the host downloads the tarball when the flag is true and the controller uploads it from Software when the flag is false. diff --git a/ROADMAP.md b/ROADMAP.md index e4e27651..c24a915e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,8 @@ Modeled on [cup](https://github.com/splunk/cup) agent mode (detect agent env, JS - [ ] Hide credentials in Ansible output (`no_log`, stop putting secrets on `command:` argv) so `-v` can stay native debug without leaking admin/Splunkbase/SHC/API secrets; keep agents off the transcript — [#88](https://github.com/splunk/splunk-platform-automator/issues/88) - [x] Main `spa` skill (cup-style `/spa` entry) plus `spa environment` registry — [#54](https://github.com/splunk/splunk-platform-automator/issues/54) - [x] `spa skills install` / `spa skills remove` (Claude / Cursor / Codex and the other cup IDE paths, plus Antigravity) from `$SPA_HOME/skills/spa/` — [#55](https://github.com/splunk/splunk-platform-automator/issues/55) -- [ ] Skills, agents, and runbooks pack (full operator loop; calls `spa` only). Add-app skills use Splunkbase/vendor **install docs** (when linked) to set `target_roles` / `splunk_config.yml`, e.g. [Unix TA Install](https://splunk.github.io/splunk-add-on-for-unix-and-linux/Install/) — [#67](https://github.com/splunk/splunk-platform-automator/issues/67). Blocked by [#55](https://github.com/splunk/splunk-platform-automator/issues/55) +- [x] Skills, agents, and runbooks pack (full operator loop; calls `spa` only). Add-app skills use Splunkbase/vendor **install docs** (when linked) to set `target_roles` / `splunk_config.yml`, e.g. [Unix TA Install](https://splunk.github.io/splunk-add-on-for-unix-and-linux/Install/) — [#67](https://github.com/splunk/splunk-platform-automator/issues/67). Blocked by [#55](https://github.com/splunk/splunk-platform-automator/issues/55) +- [ ] Local archive check, AWS lab settings, and apps/upgrade/health references: `splunk_archive_glob` with the missing-file download URLs and `SplunkDownloadConfig` descriptions; the nine AWS lab `spa config set` keys and `spa validate --splunk-config aws` in create-config; Unix TA install docs, `spa config merge itsi-minimal`, Java on the search host, `upgrade.md`, and `health.md` — [#125](https://github.com/splunk/splunk-platform-automator/issues/125). Split from [#67](https://github.com/splunk/splunk-platform-automator/issues/67) - [ ] Apps agent interface: search shows where app 833 belongs, a Splunkbase timeout is retryable, `spa apps auth check` reports whether credentials are set, and `spa config merge itsi-minimal` applies the four-app ITSI set — [#124](https://github.com/splunk/splunk-platform-automator/issues/124). Slice of [#67](https://github.com/splunk/splunk-platform-automator/issues/67) - [x] Splunkbase search / snippet / download (`spa apps`; app-management skill; never display Splunkbase passwords — [secrets-handling.md](skills/spa/spa-create-config/references/secrets-handling.md)) — [#59](https://github.com/splunk/splunk-platform-automator/issues/59). Config add/remove is [#76](https://github.com/splunk/splunk-platform-automator/issues/76). - [ ] Optional [cup](https://github.com/splunk/cup) integration (plugin; spa works without cup; cup uses `spa hosts copy` / `ssh` for file transfer) — [#62](https://github.com/splunk/splunk-platform-automator/issues/62) diff --git a/ansible/plugins/inventory/schema.py b/ansible/plugins/inventory/schema.py index 79fbe743..5aef67a0 100644 --- a/ansible/plugins/inventory/schema.py +++ b/ansible/plugins/inventory/schema.py @@ -96,7 +96,7 @@ class VirtualBoxConfig(BaseModel): class AwsTerraformConfig(BaseModel): """AWS configuration for Terraform provisioning.""" - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='forbid') region: Optional[str] = Field(None, description="AWS region Terraform uses. Default eu-central-1. Omit the key to keep that.") ami_id: Optional[str] = Field( diff --git a/docs/commands.md b/docs/commands.md index 46fcf90c..cbee9998 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -289,11 +289,12 @@ Validate splunk_config.yml (schema, Software/baseconfig/local apps, inventory) | --- | --- | | `--config` | Validate this file instead of the environment config | | `--details` | Include the full license inventory | +| `--splunk-config` | Check this provider's selections from the environment config. aws today. | Example: ```bash -spa --agent validate +spa validate --splunk-config aws ``` ## `doctor` @@ -473,7 +474,7 @@ Scaffold or migrate an env dir; name-only uses the default parent (~/Splunk-Plat | Flag | Description | | --- | --- | | `--example` | Topology example id | -| `--provider` | Provider example: aws, virtualbox, or external (default: aws, or providers.yml) | +| `--provider` | aws, virtualbox, or external. Required unless a default is saved (spa environment set --provider NAME) | | `--env-dir` | One-off parent (requires --name); does not change the default parent | | `--name` | Registry name (default: folder basename) | | `--force` | Adopt/refresh an existing env and register it (keep splunk_config.yml unless --example) | @@ -482,7 +483,7 @@ Scaffold or migrate an env dir; name-only uses the default parent (~/Splunk-Plat Example: ```bash -spa environment init --example cm_2idxc_sh my-lab +spa environment init --example cm_2idxc_sh --provider aws my-lab ``` ## `environment init list` @@ -497,7 +498,7 @@ spa environment init list ## `environment set` -User-level defaults in paths.yml (env parent, Software, baseconfig, apps) and providers.yml (only when not aws) +User-level defaults in paths.yml (env parent, Software, baseconfig, apps) and the default provider for init in providers.yml | Flag | Description | | --- | --- | @@ -506,7 +507,7 @@ User-level defaults in paths.yml (env parent, Software, baseconfig, apps) and pr | `--baseconfig-dir` | PS baseconfig apps directory | | `--apps-dir` | Local apps directory | | `--default` | Registered environment used when cwd does not select one | -| `--provider` | Default provider for init: aws, virtualbox, or external | +| `--provider` | Save the default provider for init: aws, virtualbox, or external. Saved even for aws, so init then accepts no --provider | Example: @@ -514,6 +515,22 @@ Example: spa environment set --software-dir ~/Software --apps-dir ~/labs/apps ``` +## `environment cleanup` + +Remove registry entries whose env directory is missing. Lists them first; no directories are deleted + +Requires confirmation: yes (`--yes`). + +| Flag | Description | +| --- | --- | +| `-y`, `--yes` | Remove the listed registry entries (no directories are deleted) | + +Example: + +```bash +spa environment cleanup --yes +``` + ## `environment remove` Unregister and delete the env directory (not spa destroy) diff --git a/docs/contributing.md b/docs/contributing.md index 124ac9ac..46fe7982 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -26,7 +26,6 @@ Canonical skills live only under `skills/spa/`: | Load SPA first (Software, envs, operator loop) | `spa` (`/spa`) | | Design `splunk_config.yml` | `spa-create-config` | | Find and configure Splunkbase apps | `spa-apps` | -| Add app-scope test scenarios | `spa-add-test-scenario` | Cursor symlinks are not part of this repo. Install the packages into the user IDE directory with `spa skills install` (see [`skills/spa/README.md`](../skills/spa/README.md)). Do not duplicate skill content. diff --git a/docs/user-guide.md b/docs/user-guide.md index 44509669..8c9621ae 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -61,7 +61,7 @@ spa init --software-dir ~/Software --example cm_2idxc_sh my-lab ~/Software/Splunk_Enterprise.lic ``` -User-level defaults live in `~/.config/spa/paths.yml` (not secrets). `spa environment set --env-dir DIR` changes the parent for name-only init (omit when it is `~/Splunk-Platform-Automator`). `spa environment set --software-dir DIR` / `--baseconfig-dir DIR` / `--apps-dir DIR` set the shared installer, PS baseconfig, and local-apps trees for every env. `--software-dir` also sets `baseconfig_dir` when you do not pass `--baseconfig-dir`. If a path key has never been set, `spa init` stores the first directory it is given or discovers; later one-off init options remain specific to that env and do not replace an established global. `spa environment set --default NAME` selects a registered environment. `spa environment set --provider virtualbox` writes `~/.config/spa/providers.yml`; aws is the implicit default and does not create that file. One-off dest (`spa init /my/custom/folder/my-lab` or `--env-dir` + `--name`) does not change the default parent. +User-level defaults live in `~/.config/spa/paths.yml` (not secrets). `spa environment set --env-dir DIR` changes the parent for name-only init (omit when it is `~/Splunk-Platform-Automator`). `spa environment set --software-dir DIR` / `--baseconfig-dir DIR` / `--apps-dir DIR` set the shared installer, PS baseconfig, and local-apps trees for every env. `--software-dir` also sets `baseconfig_dir` when you do not pass `--baseconfig-dir`. If a path key has never been set, `spa init` stores the first directory it is given or discovers; later one-off init options remain specific to that env and do not replace an established global. `spa environment set --default NAME` selects a registered environment. `spa environment set --provider NAME` writes `~/.config/spa/providers.yml` with the default provider for init (aws, virtualbox, or external). Once it is saved, `spa init` uses it when `--provider` is omitted. With no saved default, `spa init` stops and asks for `--provider`. One-off dest (`spa init /my/custom/folder/my-lab` or `--env-dir` + `--name`) does not change the default parent. Later per-env knobs on `spa environment set --env NAME` will write that env’s `.spa.yml`, not `paths.yml`. @@ -115,7 +115,7 @@ flowchart TD | Show native Ansible output during a run | `spa deploy --yes --ansible-output` | | Increase Ansible verbosity and show native output | `spa deploy --yes -v` through `-vvvv` | -Provider selection comes from `terraform.aws` or `virtualbox:` in config. Do not remove a live host from config and run `spa provision`; the current Terraform flow can terminate it. See [AWS](aws.md) for cloud decisions and [Upgrade](upgrade.md) for the ordered distributed-upgrade workflow. +Provider selection comes from `terraform.aws` or `virtualbox:` in config. Provision, deploy, destroy, suspend, resume, and environment remove wait for a yes in chat. `spa destroy --hosts` does not exist. Do not delete a live host from config and run `spa provision`; the current Terraform flow can terminate it. See [AWS](aws.md) for cloud decisions and [Upgrade](upgrade.md) for the ordered distributed-upgrade workflow. Mutating Ansible/Terraform work writes a timestamped JSONL transcript under `$SPA_ENV_DIR/logs/`. On a TTY, humans see **one live line per named group that actually runs** (for example `Deploy App deployment · Search Head Cluster apps`), with the active host and task while work is running and compact counters when it settles. Rolling upgrade groups settle once per host and include installed and target Splunk versions when reported by the playbook. There is no `[3/10]` index; unused topology simply never appears. `spa run` of every first-party playbook uses the same compact grouping, with play-name fallback for work outside known roles and functions. Failures print immediately. `NO_COLOR` / piped stderr stay uncolored. Agents keep a small JSON envelope on stdout plus `data.log`; they must not ingest the transcript unless you asked to debug a failure. Terraform instance changes (`instance_type`, disk) fold into the provision plan/apply line. On VirtualBox, provisioning shows one line per VM (`Provision VM idx1`) built from Vagrant's output, and a Vagrant error is printed before the group closes as `failed`. diff --git a/lib/spa/agent.py b/lib/spa/agent.py index 8dad4e85..7b42c2bc 100644 --- a/lib/spa/agent.py +++ b/lib/spa/agent.py @@ -344,8 +344,13 @@ def clear_envelope_identity() -> None: "flags": [ {"long": "--config", "help": "Validate this file instead of the environment config"}, {"long": "--details", "help": "Include the full license inventory", "takes_value": False}, + { + "long": "--splunk-config", + "help": "Check this provider's selections from the environment config. aws today.", + "takes_value": True, + }, ], - "example": "spa --agent validate", + "example": "spa validate --splunk-config aws", }, {"name": "doctor", "summary": "Host prerequisite checks"}, { @@ -477,7 +482,7 @@ def clear_envelope_identity() -> None: {"long": "--force", "help": "Adopt/refresh an existing env and register it (keep splunk_config.yml unless --example)"}, {"long": "--software-dir", "help": "Shared installers directory (saved in paths.yml)"}, ], - "example": "spa environment init --example cm_2idxc_sh my-lab", + "example": "spa environment init --example cm_2idxc_sh --provider aws my-lab", }, { "name": "environment init list", @@ -486,7 +491,7 @@ def clear_envelope_identity() -> None: }, { "name": "environment set", - "summary": "User-level defaults in paths.yml (env parent, Software, baseconfig, apps) and providers.yml (only when not aws)", + "summary": "User-level defaults in paths.yml (env parent, Software, baseconfig, apps) and the default provider for init in providers.yml", "flags": [ {"long": "--env-dir", "help": "Default parent for name-only init"}, {"long": "--software-dir", "help": "Shared installers directory (also sets baseconfig_dir if unset)"}, @@ -500,13 +505,22 @@ def clear_envelope_identity() -> None: }, { "long": "--provider", - "help": "Default provider for init: aws, virtualbox, or external", + "help": "Save the default provider for init: aws, virtualbox, or external. Saved even for aws, so init then accepts no --provider", "takes_value": True, "completes_from": "providers", }, ], "example": "spa environment set --software-dir ~/Software --apps-dir ~/labs/apps", }, + { + "name": "environment cleanup", + "summary": "Remove registry entries whose env directory is missing. Lists them first; no directories are deleted", + "requires_confirmation": True, + "flags": [ + {"long": "--yes", "short": "-y", "help": "Remove the listed registry entries (no directories are deleted)"}, + ], + "example": "spa environment cleanup --yes", + }, { "name": "environment remove", "summary": "Unregister and delete the env directory (not spa destroy)", diff --git a/lib/spa/api.py b/lib/spa/api.py index 725a561e..057e3698 100644 --- a/lib/spa/api.py +++ b/lib/spa/api.py @@ -96,7 +96,7 @@ def validate( self, config: Optional[str] = None, check_licenses: bool = False, - splunk_config_aws: bool = False, + splunk_config: Optional[str] = None, agent: bool = False, ) -> CommandResult: ... @@ -169,6 +169,8 @@ def environment_remove( self, name: str, confirm: bool = False, force: bool = False ) -> CommandResult: ... + def environment_cleanup(self, confirm: bool = False, agent: bool = False) -> CommandResult: ... + def config( self, action: str, @@ -995,7 +997,7 @@ def validate( self, config: Optional[str] = None, check_licenses: bool = False, - splunk_config_aws: bool = False, + splunk_config: Optional[str] = None, agent: bool = False, ) -> CommandResult: blocked = self._env_gate() @@ -1007,7 +1009,7 @@ def validate( self.paths, config=config, check_licenses=check_licenses, - splunk_config_aws=splunk_config_aws, + splunk_config=splunk_config, on_progress=self.on_progress, agent=agent, ) @@ -1256,11 +1258,38 @@ def environment_set( if provider: prov_path = set_default_provider(provider) data["provider"] = provider - data["providers_yml"] = str(prov_path) if prov_path else None + data["providers_yml"] = str(prov_path) except RegistryError as exc: return CommandResult(ok=False, error=str(exc), code=exc.code) return CommandResult(ok=True, data=data) + def environment_cleanup(self, confirm: bool = False, agent: bool = False) -> CommandResult: + """List registry entries with no directory; remove them only with confirm (--yes).""" + from spa.registry import RegistryError, cleanup_missing_environments, missing_environments + + try: + missing = missing_environments() + if not missing: + return CommandResult(ok=True, data={"removed": []}) + listing = ["missing: %s %s" % (row["name"], row["path"]) for row in missing] + gate = self._confirm_gate( + "spa environment cleanup", + confirm=confirm, + agent=agent, + details=listing, + risk="registry_cleanup", + ) + if gate is not None: + return CommandResult( + ok=False, + error="Missing environments:\n%s\n%s" % ("\n".join(listing), gate.error), + code=gate.code, + ) + removed = cleanup_missing_environments() + except RegistryError as exc: + return CommandResult(ok=False, error=str(exc), code=exc.code) + return CommandResult(ok=True, data={"removed": removed}) + def environment_remove( self, name: str, confirm: bool = False, force: bool = False ) -> CommandResult: @@ -1999,7 +2028,10 @@ def aws(self, argv: Optional[List[str]] = None) -> CommandResult: return blocked from spa import aws as aws_mod - return aws_mod.execute_argv(list(argv or [])) + return aws_mod.execute_argv( + list(argv or []), + environment_region=aws_mod.environment_config_region(self.paths.config_file), + ) def licenses( self, diff --git a/lib/spa/aws.py b/lib/spa/aws.py index 91fa47ab..5e691545 100755 --- a/lib/spa/aws.py +++ b/lib/spa/aws.py @@ -242,10 +242,30 @@ def _region_from_aws_config() -> _ResolvedRegion: return _ResolvedRegion(None, None) -def resolve_region(explicit: Optional[str] = None) -> _ResolvedRegion: - """Prefer --region, then AWS_REGION, then AWS_DEFAULT_REGION, then the AWS config.""" +def environment_config_region(config_file: Any) -> Optional[str]: + """terraform.aws.region from an environment's splunk_config.yml, or None. Never raises.""" + try: + from spa.preflight import load_config_mapping + from pathlib import Path + + path = os.fspath(config_file) + if not os.path.isfile(path): + return None + data = load_config_mapping(Path(path)) + except Exception: + return None + block = (data.get("terraform") or {}).get("aws") if isinstance(data, dict) else None + value = block.get("region") if isinstance(block, dict) else None + text = str(value).strip() if value else "" + return text or None + + +def resolve_region(explicit: Optional[str] = None, environment_region: Optional[str] = None) -> _ResolvedRegion: + """Prefer --region, then the environment's terraform.aws.region, then AWS_REGION, AWS_DEFAULT_REGION, the AWS config.""" if explicit: return _ResolvedRegion(explicit, "--region") + if environment_region: + return _ResolvedRegion(environment_region, "terraform.aws.region") for name in ("AWS_REGION", "AWS_DEFAULT_REGION"): value = os.environ.get(name, "").strip() if value: @@ -258,6 +278,8 @@ def region_notice(resolved: _ResolvedRegion) -> str: return "" if resolved.source == "--region": return "Using region %s from --region." % resolved.region + if resolved.source == "terraform.aws.region": + return "Using region %s from terraform.aws.region in the environment config." % resolved.region if resolved.source in ("AWS_REGION", "AWS_DEFAULT_REGION"): return "Using region %s from %s." % (resolved.region, resolved.source) profile = resolved.profile or "default" @@ -1202,7 +1224,7 @@ def _list_amis_result(session: Any, args: Any): ) -def execute(args: Any): +def execute(args: Any, environment_region: Optional[str] = None): from spa.api import CommandResult kind = getattr(args, "aws_list_kind", None) @@ -1239,13 +1261,13 @@ def execute(args: Any): return _aws_result(check_auth(_session())) if kind == "regions": - resolved = resolve_region(getattr(args, "region", None)) + resolved = resolve_region(getattr(args, "region", None), environment_region) data = list_regions(_session(), selected_region=resolved.region) _stamp_region(data, resolved) return _aws_result(data) if kind in {"amis", "instance-types", "key-pairs", "security-groups"}: - resolved = resolve_region(getattr(args, "region", None)) + resolved = resolve_region(getattr(args, "region", None), environment_region) if not resolved.region: return CommandResult( ok=False, @@ -1272,7 +1294,7 @@ def execute(args: Any): return _finish(_aws_result(list_key_pairs(session, args.region)), resolved) return _finish(_aws_result(list_security_groups(session, args.region)), resolved) - resolved = resolve_region(getattr(args, "region", None)) + resolved = resolve_region(getattr(args, "region", None), environment_region) if not resolved.region: return CommandResult( ok=False, @@ -1372,17 +1394,17 @@ def _compact_validate_payload(data: Dict[str, Any]) -> Dict[str, Any]: return projected -def execute_argv(argv: Optional[List[str]] = None): +def execute_argv(argv: Optional[List[str]] = None, environment_region: Optional[str] = None): raw = list(sys.argv[1:] if argv is None else argv) retired = _retired_list_message(raw) if retired: from spa.api import CommandResult return CommandResult(ok=False, error=retired, code=2) - return execute(build_parser().parse_args(raw)) + return execute(build_parser().parse_args(raw), environment_region=environment_region) -def main(argv: Optional[List[str]] = None, as_agent: bool = False) -> int: +def main(argv: Optional[List[str]] = None, as_agent: bool = False, environment_region: Optional[str] = None) -> int: raw = list(sys.argv[1:] if argv is None else argv) AwsArgumentParser.json_errors = bool(as_agent or "--json" in raw) try: @@ -1396,7 +1418,7 @@ def main(argv: Optional[List[str]] = None, as_agent: bool = False) -> int: _err(retired) return 2 args = build_parser().parse_args(raw) - result = execute(args) + result = execute(args, environment_region=environment_region) as_json = bool(args.json or as_agent) shown = result.data if ( diff --git a/lib/spa/cli.py b/lib/spa/cli.py index e9753bd8..16f9e71e 100644 --- a/lib/spa/cli.py +++ b/lib/spa/cli.py @@ -646,9 +646,27 @@ def _print_completion_candidates(session, extra: Sequence[str]) -> int: "Coverage: spa config keys\n" "Docs dump: spa config schema --markdown" ) +_HEADER_FIELDS_HELP = """\ +Header fields (each optional; an omitted field is removed): + --intent TEXT Why this environment exists: config-test | app-lab | production-like + --requirements TEXT Constraints the design must meet: ingest=; users=; DR= + --sva TEXT SVA target and lab compromises, e.g. S1 (single node) or M2 (lab gaps listed) + --placement TEXT Where roles run: SVA-aligned | lab-minimal | hybrid, with a host summary + --license TEXT How Splunk is licensed: trial, or the license file set in splunk_defaults + --installer TEXT Installer used for Splunk, e.g. the Linux tgz in the Software directory + --os TEXT Operating system for the hosts, e.g. RHEL 10 + --aws TEXT AWS placement notes: region, AMI, instance type, or AWS API not available + --deploy TEXT Command that deploys this environment, e.g. spa provision --yes && spa deploy --yes + +Each value is one line of text. A line break, a certificate or license marker, or a secret-shaped value is refused. + +Example: + spa config header set --intent config-test --sva S1 --os "RHEL 10" --deploy "spa provision --yes && spa deploy --yes" --yes +""" HEADER_NEEDS_ACTION = ( "spa config header needs an action.\n" - "Set: spa config header set --intent TEXT" + "Set the leading comment: spa config header set --intent TEXT [FIELD TEXT ...] --yes\n\n" + + _HEADER_FIELDS_HELP ) _HEADER_FIELDS = ( "intent", @@ -1067,10 +1085,13 @@ def _run_init(args, session, as_agent: bool) -> int: registry_name=registry_name, ) if as_agent: + if not result.ok: + emit(False, error=result.error, as_agent=True) + return result.code payload = dict(result.data or {}) payload["env_dir"] = str(dest.resolve()) payload["name"] = registry_name - emit(result.ok, data=payload, error=result.error, as_agent=True) + emit(True, data=payload, as_agent=True) return result.code _print_init_messages(result) if result.error: @@ -1127,12 +1148,26 @@ def _run_environment(args, session, paths, as_agent: bool, env_parser) -> int: print("Updated %s" % data["paths_yml"]) if data.get("environments_yml"): print("Default environment is %s" % data["default"]) - if "providers_yml" in data: - if data.get("providers_yml"): - print("Updated %s" % data["providers_yml"]) - else: - print("Default provider is aws (providers.yml removed)") + if data.get("providers_yml"): + print("Updated %s (default provider: %s)" % (data["providers_yml"], data.get("provider"))) return result.code + if action == "cleanup": + result = session.environment_cleanup( + confirm=bool(getattr(args, "yes", False)), + agent=as_agent, + ) + if as_agent: + return _emit_result(result, True) + if result.error: + print(result.error, file=sys.stderr) + return result.code + removed = (result.data or {}).get("removed") or [] + if not removed: + print("No missing environments.") + return 0 + for row in removed: + print("Removed %s (%s)" % (row.get("name"), row.get("path"))) + return 0 if action == "remove": result = session.environment_remove( args.name, @@ -1488,6 +1523,8 @@ def _config_mutation_flags(config_parser, *, explicit_default=False): "set", help=_header_set_help, description=_header_set_help, + epilog=_HEADER_FIELDS_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, ) p_config_header_set.set_defaults(canonical_action="header-set") _add_mode_flags(p_config_header_set) @@ -1807,9 +1844,10 @@ def _config_mutation_flags(config_parser, *, explicit_default=False): help="Include the full license inventory", ) p_val.add_argument( - "--splunk-config-aws", - action="store_true", - help="Include legacy splunk_config_aws compatibility checks", + "--splunk-config", + metavar="PROVIDER", + choices=["aws"], + help="Check this provider's selections from the environment config. aws today.", ) _add_env_selector(p_val) @@ -1976,7 +2014,20 @@ def _venv_scope(parser, *, mutate: bool, install_flags: bool) -> None: "--provider", dest="set_provider", metavar="NAME", - help="Default provider for init: aws, virtualbox, or external (aws is implicit; any other value is saved in providers.yml)", + help="Save the default provider for init (aws, virtualbox, or external) in providers.yml, so init does not need --provider", + ) + p_env_cleanup = _add_action( + env_sub, + "cleanup", + help="Remove registry entries whose env directory is missing. Lists them; confirm with --yes", + ) + _add_mode_flags(p_env_cleanup) + p_env_cleanup.add_argument( + "-y", + "--yes", + action="store_true", + default=argparse.SUPPRESS, + help="Remove the listed registry entries (no directories are deleted)", ) p_env_remove = _add_action( env_sub, @@ -2597,7 +2648,7 @@ def _roles(): result = session.validate( config=args.config, check_licenses=args.check_licenses, - splunk_config_aws=args.splunk_config_aws, + splunk_config=args.splunk_config, agent=as_agent, ) if as_agent: @@ -3002,8 +3053,10 @@ def _venv_step(event): if args.command == "aws": from spa import aws as aws_mod + paths = getattr(session, "paths", None) + env_region = aws_mod.environment_config_region(paths.config_file) if paths else None try: - return aws_mod.main([*native, *extra], as_agent=as_agent) + return aws_mod.main([*native, *extra], as_agent=as_agent, environment_region=env_region) except SystemExit as exc: return exc.code if isinstance(exc.code, int) else 1 diff --git a/lib/spa/confirm.py b/lib/spa/confirm.py index e0f9d8e9..5d9ee19e 100644 --- a/lib/spa/confirm.py +++ b/lib/spa/confirm.py @@ -44,6 +44,7 @@ def requires_confirmation(*, risk: Optional[str] = None, missing_metadata: bool "local_destructive": "will delete and recreate the Python environment for this framework", "skills_remove": "will remove SPA skills from the IDE skill directory", "config_edit": "will change splunk_config.yml", + "registry_cleanup": "will remove registry entries for missing environments (no directories are deleted)", } diff --git a/lib/spa/init.py b/lib/spa/init.py index 8628e6f5..2cf405a3 100644 --- a/lib/spa/init.py +++ b/lib/spa/init.py @@ -546,6 +546,47 @@ def _normalize_example_name(name: str) -> str: return name +INIT_PROVIDER_REQUIRED = ( + "spa init needs --provider: choose aws, virtualbox, or external.\n" + "Or save a default once: spa environment set --provider NAME" +) + + +def required_init_provider(provider: Optional[str]) -> str: + """Explicit --provider, else the default saved by spa environment set. Never a silent default.""" + from spa.registry import explicit_default_provider + + chosen = (provider or "").strip().lower() + if chosen: + return chosen + saved = explicit_default_provider() + if saved: + return saved + raise InitError(INIT_PROVIDER_REQUIRED, code=2) + + +def _composes_config( + dest: Path, + source: Path, + example_set: bool, + migrate_set: bool, + from_dir: Optional[Path], + force: bool, +) -> bool: + """True when this init writes a new splunk_config.yml from a topology and provider.""" + if example_set: + return True + if migrate_set: + return False + if is_old_clone_tree(dest) and from_dir is None: + return False + if source != dest and has_config(source): + return False + if force and (has_config(dest) or is_spa_env(dest)): + return False + return True + + def resolve_example_pair( spa_home: Path, example: Optional[str], provider: Optional[str] ) -> Tuple[str, str]: @@ -774,6 +815,8 @@ def _init_env( pip_pkgs = list(pip_pkgs or []) dest = dest.resolve() spa_home = spa_home.resolve() + if _composes_config(dest, (from_dir or spa_home).resolve(), example_set, migrate_set, from_dir, force): + provider = required_init_provider(provider) if example_set: resolve_example_pair(spa_home, example, provider) dest.mkdir(parents=True, exist_ok=True) diff --git a/lib/spa/registry.py b/lib/spa/registry.py index 55ecd523..1755ba4b 100644 --- a/lib/spa/registry.py +++ b/lib/spa/registry.py @@ -221,28 +221,30 @@ def canonicalize_provider(value: Optional[str]) -> str: return PROVIDER_ALIASES[raw] -def load_default_provider(environ: Optional[Dict[str, str]] = None) -> str: +def explicit_default_provider(environ: Optional[Dict[str, str]] = None) -> Optional[str]: + """The provider saved by `spa environment set --provider`, or None when none was saved.""" data = _load_mapping(user_providers_yml(environ)) raw = data.get("default") if not raw: - return DEFAULT_PROVIDER + return None try: return canonicalize_provider(str(raw)) except RegistryError: - return DEFAULT_PROVIDER + return None -def set_default_provider(value: str, environ: Optional[Dict[str, str]] = None) -> Optional[Path]: +def load_default_provider(environ: Optional[Dict[str, str]] = None) -> str: + return explicit_default_provider(environ) or DEFAULT_PROVIDER + + +def set_default_provider(value: str, environ: Optional[Dict[str, str]] = None) -> Path: + """Save the default provider. aws is saved too, so it counts as an explicit choice.""" canon = canonicalize_provider(value) path = user_providers_yml(environ) - if canon == DEFAULT_PROVIDER: - if path.is_file(): - path.unlink() - return None path.parent.mkdir(parents=True, exist_ok=True) body = ( "# Splunk Platform Automator default provider (not secrets).\n" - "# Omit this file when the default is aws.\n" + "# spa init uses this provider when --provider is not given.\n" + _dump_yaml({"default": canon}) ) path.write_text(body, encoding="utf-8") @@ -449,6 +451,28 @@ def env_name_for_path(path, environ: Optional[Dict[str, str]] = None) -> Optiona return None +def missing_environments(environ: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]: + """Registered environments whose directory no longer exists.""" + return [row for row in list_environments(environ) if not row["exists"]] + + +def cleanup_missing_environments(environ: Optional[Dict[str, str]] = None) -> List[Dict[str, str]]: + """Drop registry entries whose directory is missing. Never deletes a directory.""" + missing = missing_environments(environ) + if not missing: + return [] + data = load_environments(environ) + envs: Dict[str, Any] = dict(data.get("environments") or {}) + removed_names = {row["name"] for row in missing} + for name in removed_names: + envs.pop(name, None) + data["environments"] = envs + if (data.get("default") or "") in removed_names: + data["default"] = next(iter(sorted(envs)), "") + save_environments(data, environ) + return [{"name": row["name"], "path": row["path"]} for row in missing] + + def list_environments(environ: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]: data = load_environments(environ) default = (data.get("default") or "").strip() diff --git a/lib/spa/usage.py b/lib/spa/usage.py index 8435a2a2..62ebcc7d 100644 --- a/lib/spa/usage.py +++ b/lib/spa/usage.py @@ -17,7 +17,8 @@ "Example: spa config provider aws" ) INIT_PROVIDER_HELP = ( - "Provider example: aws, virtualbox, or external (default: aws, or providers.yml)" + "aws, virtualbox, or external. Required unless a default is saved " + "(spa environment set --provider NAME)" ) SET_PATH_HELP = ( "A config path. Find one: spa config search QUERY. " diff --git a/lib/spa/validate.py b/lib/spa/validate.py index 575a13bb..7739f19f 100644 --- a/lib/spa/validate.py +++ b/lib/spa/validate.py @@ -110,11 +110,13 @@ def validate_env( *, config: Optional[str] = None, check_licenses: bool = False, - splunk_config_aws: bool = False, + splunk_config: Optional[str] = None, on_progress: Optional[ProgressCallback] = None, agent: bool = False, ) -> CommandResult: """Run validation steps and return structured results (no printing).""" + if splunk_config is not None and splunk_config != "aws": + return CommandResult(ok=False, error="spa validate --splunk-config aws", code=2) if config: config_path = resolve_config_path(config, paths) overlay = os.environ.copy() @@ -326,7 +328,7 @@ def validate_env( return CommandResult(ok=False, error=msg, data=data, code=1) steps.append({"id": "license_files", "ok": True, "message": "License check OK"}) - if splunk_config_aws: + if splunk_config == "aws": try: import yaml except ImportError: @@ -407,7 +409,12 @@ def run(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(description="Validate splunk_config.yml") parser.add_argument("--check-licenses", action="store_true") - parser.add_argument("--splunk-config-aws", action="store_true") + parser.add_argument( + "--splunk-config", + metavar="PROVIDER", + choices=["aws"], + help="Check this provider's selections from the environment config. aws today.", + ) parser.add_argument( "--config", metavar="FILE", @@ -417,7 +424,7 @@ def run(argv: Optional[List[str]] = None) -> int: result = validate_env( config=args.config, check_licenses=args.check_licenses, - splunk_config_aws=args.splunk_config_aws, + splunk_config=args.splunk_config, ) text = format_validate_text(result) sys.stdout.write(text) diff --git a/skills/spa/README.md b/skills/spa/README.md index 40a1fe3e..f4a3fcbd 100644 --- a/skills/spa/README.md +++ b/skills/spa/README.md @@ -7,9 +7,8 @@ Framework-neutral [Agent Skills](https://agentskills.io/specification.md) packag | Skill | Purpose | |-------|---------| | [spa](spa/) | Product entry (`/spa`): Software, environments, operator loop | -| [spa-create-config](spa-create-config/) | Design and write `config/splunk_config.yml` (plan mode, SVA topology, AWS, licenses, validation) | +| [spa-create-config](spa-create-config/) | Design `config/splunk_config.yml` through `spa config` commands only, never by hand (plan mode, SVA topology, AWS, licenses, header, validation) | | [spa-apps](spa-apps/) | Splunkbase search, kind-aware apps[] snippets, optional download | -| [spa-add-test-scenario](spa-add-test-scenario/) | Distill app-scope scenario tests under `tests/configs/app_scope/` | Human guide: [docs/user-guide.md](../../docs/user-guide.md). Repo index: [AGENTS.md](../../AGENTS.md). @@ -31,7 +30,7 @@ Install prefers a symlink to `$SPA_HOME/skills/spa/`. An existing director ## Other agents -Load `skills/spa/spa/SKILL.md` first, then a task skill when the user works on `splunk_config.yml`, Splunkbase apps, or app-scope tests. Follow `references/` on demand. +Load `skills/spa/spa/SKILL.md` first, then a task skill when the user works on `splunk_config.yml` or Splunkbase apps. Follow `references/` on demand. App-scope scenarios are documented in [tests/README.md](../../tests/README.md). ## Validation (optional) @@ -41,5 +40,4 @@ If you have the Agent Skills reference tooling installed: skills-ref validate ./skills/spa/spa skills-ref validate ./skills/spa/spa-create-config skills-ref validate ./skills/spa/spa-apps -skills-ref validate ./skills/spa/spa-add-test-scenario ``` diff --git a/skills/spa/spa-add-test-scenario/SKILL.md b/skills/spa/spa-add-test-scenario/SKILL.md deleted file mode 100644 index 964112c0..00000000 --- a/skills/spa/spa-add-test-scenario/SKILL.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -name: spa-add-test-scenario -description: >- - Use when adding app scope/routing test coverage (deployer, CM, DS, direct). - Distills splunk_config.yml into tests/configs/app_scope// with - expected_scope.json. NOT for flat deployment test configs under tests/configs/*.yml. - In Cursor invoke with /spa-add-test-scenario. -license: Proprietary -compatibility: Requires SPA repo root (ansible.cfg, tests/). No AWS or SSH required. -metadata: - product: splunk-platform-automator - version: "1.0.0" ---- - -# spa-add-test-scenario — App scope test scenarios - -## Two config types — pick the right one - -| Type | Location | Test runner | Purpose | -|------|----------|-------------|---------| -| **App scope scenario** | `tests/configs/app_scope//splunk_config.yml` | `test_app_scope_scenarios.py` | Scope/routing logic only; no SSH, no AWS | -| **Deployment config** | `tests/configs/.yml` (flat file) | `test_deployment.py` | Full pipeline on AWS (provision, install, deploy) | - -This skill covers **app scope scenarios** only. A large config like -`config/splunk_config.yml` or `tests/configs/2site-idxc_shc_mc_ds_sh_hf_uf_itsi_apps.yml` -can be **distilled** into an app scope scenario, but that is optional — it does not -replace a deployment test config. - -### Deployment test configs (out of scope for this skill) - -To add a full deployment test: - -1. Create `tests/configs/.yml` (flat YAML at top level of `configs/`). -2. Run `./tests/run_deployment_tests.sh -k ""`. -3. No `expected_scope.json`, no `debug_app_scope.yml`. - -## Overview - -App scope scenarios live under `tests/configs/app_scope//`. Each has: - -- `splunk_config.yml` — distilled input config -- `expected_scope.json` — scope output snapshot (optional but recommended) - -The test runner **auto-discovers** scenarios (subdirs containing `splunk_config.yml`). -No registration beyond creating the directory. - -``` -tests/configs/app_scope/ -├── / -│ ├── splunk_config.yml -│ └── expected_scope.json -├── output/ # generated at test-time (gitignored) -│ └── _scope.json -└── ... -``` - -Key files: - -- `tests/test_app_scope_scenarios.py` — discovery, playbook runner, assertions, snapshots -- `tests/scope_assertions.py` — reusable assertion helpers -- `tests/run_app_scope_scenarios_tests.sh` — preferred way to run tests (sets up venv) -- `ansible/verification/debug_app_scope.yml` — scope playbook - -## Workflow - -### 1. Check existing coverage - -Before adding a scenario, scan existing configs under `tests/configs/app_scope/`. -Each `splunk_config.yml` documents itself in the header comment: - -```yaml -# Scenario: -# -# Verifies: . -``` - -Read a few headers (and optionally their `expected_scope.json`) to confirm the -pattern you need is **not already covered**. Compare against the source config and -focus on gaps in these categories: - -| Pattern category | Examples | -|-----------------|----------| -| Deployment paths | deployer, CM, DS, direct | -| Filters | `shc_whitelist`, `shc_blacklist`, `idxc_whitelist`, `hosts_whitelist`, `sc_whitelist` | -| App features | `premium_app`, `itsi_content_pack`, `content_pack_apps`, `deployment_target: direct` | -| Multi-role apps | Same app targeting `search_head` + `indexer` + `heavy_forwarder` | -| Duplicate apps | Same app name with different `target_roles` / serverclass | -| Customizations | `local_configs`, `run_playbook`, `run_playbook_after_restart`, `extra_vars` | - -### 2. Create the scenario config - -Create `tests/configs/app_scope//splunk_config.yml`: - -- Start with a YAML header comment: scenario name, topology summary, what it verifies. -- **Distill** the source config: keep only active parts relevant to the patterns. - Remove vault-encrypted values and commented blocks. -- Keep infrastructure minimal but representative (e.g. 2 indexers, 3 SH per cluster). -- Use placeholder values for tokens/credentials (e.g. `test-hec-token-placeholder`). - -Header format: - -```yaml ---- -# Scenario: -# -# Verifies: . -plugin: splunk-platform-automator -``` - -### 3. Generate the expected snapshot - -**Option A — pytest auto-update (preferred after first run):** - -```bash -./tests/run_app_scope_scenarios_tests.sh -k "" -v -UPDATE_SCOPE_SNAPSHOTS=1 ./tests/run_app_scope_scenarios_tests.sh -k "snapshot and " -v -``` - -This writes `tests/configs/app_scope//expected_scope.json` from -the normalized actual output. Review the diff before committing. - -**Option B — manual playbook run (initial generation or debugging):** - -```bash -mkdir -p tests/configs/app_scope/output/.ansible_tmp/remote - -ANSIBLE_CONFIG="$(pwd)/ansible.cfg" \ -ANSIBLE_LOCAL_TEMP="$(pwd)/tests/configs/app_scope/output/.ansible_tmp" \ -ANSIBLE_REMOTE_TEMP="$(pwd)/tests/configs/app_scope/output/.ansible_tmp/remote" \ -spa run verification/debug_app_scope -- \ - -i tests/configs/app_scope//splunk_config.yml \ - -e run_scope_locally=true \ - -e "scope_output_path=$(pwd)/tests/configs/app_scope/output/_scope.json" \ - -e assert_scope_invariants=true -``` - -Verify exit code is 0, then copy the output to -`tests/configs/app_scope//expected_scope.json`. - -Note: when running via pytest, `_run_scope_playbook()` sets the Ansible temp dirs -automatically — manual env vars are only needed for standalone playbook runs. - -### 4. Add scenario-specific assertions (optional but recommended) - -Add an `elif scenario_name == "":` block inside `test_scenario_scope_assertions` -in `tests/test_app_scope_scenarios.py`. Use helpers from `scope_assertions.py`: - -| Helper | Purpose | -|--------|---------| -| `assert_deployers_entry(scope, host, apps, target_hosts)` | Specific deployer's apps and targets | -| `assert_deployer_apps(scope, apps)` | First deployer's app list | -| `assert_deployer_has_app_with_config(scope, app, state=, has_content_pack_apps=)` | Deployer app config details | -| `assert_deployer_target_hosts(scope, hosts)` | First deployer's target hosts | -| `assert_cluster_manager_apps(scope, apps)` | CM app list | -| `assert_cluster_manager_target_hosts(scope, hosts)` | CM target hosts | -| `assert_ds_app_count(scope, count)` | DS app count | -| `assert_ds_app_target_hosts(scope, app, hosts)` | DS app target hosts | -| `assert_ds_same_app_twice(scope, app, target_hosts_per_entry)` | Duplicate app entries on DS | -| `assert_direct_on_scope(scope, host, app, expected)` | Direct scope `on_scope` flag | -| `get_app_on_direct_for_host(scope, host, app)` | Direct scope entry for inspection | - -Write assertions that validate **each deployment path** the scenario exercises. - -Scenarios without a dedicated block fall through to generic structure checks -(`direct_scope`, `deployer`, `cluster_manager`, `deployment_server` are lists). -That is enough when `expected_scope.json` provides full coverage (see `sh_filters`). - -### 5. Verify - -Run from project root: - -```bash -./tests/run_app_scope_scenarios_tests.sh -k "" -v -# or all local tests (includes app scope scenarios): -./tests/run_local_tests.sh -k "" -v -``` - -The run script creates/activates `tests/.venv` and installs `ansible-core`, -`jmespath`, and `lxml` (required for the inventory plugin). - -All applicable tests must pass: - -- `test_scenario_scope_playbook_succeeds[]` — playbook runs without error -- `test_scenario_scope_assertions[]` — assertions pass (custom or generic fallback) -- `test_scenario_scope_matches_snapshot[]` — only when `expected_scope.json` exists - -To update snapshots after intentional scope changes: - -```bash -UPDATE_SCOPE_SNAPSHOTS=1 ./tests/run_app_scope_scenarios_tests.sh -k "snapshot and " -v -``` - -## Checklist - -- [ ] Confirmed this is an **app scope** scenario, not a deployment config -- [ ] Pattern not already covered (checked `# Scenario:` headers under `tests/configs/app_scope/`) -- [ ] Directory created: `tests/configs/app_scope//` -- [ ] `splunk_config.yml` has header comment with scenario description -- [ ] Config distilled (no vault values, no commented blocks, minimal infra) -- [ ] Playbook ran successfully (exit code 0) -- [ ] `expected_scope.json` saved (via `UPDATE_SCOPE_SNAPSHOTS=1` or manual copy) -- [ ] Custom assertions added in `test_app_scope_scenarios.py` (if needed beyond snapshot) -- [ ] `./tests/run_app_scope_scenarios_tests.sh -k "" -v` passes -- [ ] `name` in SKILL.md frontmatter matches folder `spa-add-test-scenario` diff --git a/skills/spa/spa-apps/SKILL.md b/skills/spa/spa-apps/SKILL.md index bd0e95f6..948a6e55 100644 --- a/skills/spa/spa-apps/SKILL.md +++ b/skills/spa/spa-apps/SKILL.md @@ -27,6 +27,10 @@ Agents call `bin/spa` only. Never auto-run `spa deploy`. Use `spa --agent apps a - Single-app deploy/remove on hosts → not shipped yet (#68) - `premium_app: es` → not valid (#60) +## Config boundary (mandatory) + +Never edit `config/splunk_config.yml` by hand for apps. `spa --agent apps add` and `spa --agent apps remove` write `splunk_app_deployment` for you. Any other setting (SVA, OS, AWS, licenses, the header) goes through `spa config` with [spa-create-config](../spa-create-config/SKILL.md). Validation, provision, and deploy stay in [/spa](../spa/SKILL.md), which waits for your yes. + ## Secrets Follow [secrets-handling.md](../spa-create-config/references/secrets-handling.md). Report Splunkbase env vars as **set** / **not set**. Never print passwords. YAML creds stay `lookup('env', ...)`. diff --git a/skills/spa/spa-create-config/SKILL.md b/skills/spa/spa-create-config/SKILL.md index 138d122d..43daa48b 100644 --- a/skills/spa/spa-create-config/SKILL.md +++ b/skills/spa/spa-create-config/SKILL.md @@ -1,11 +1,12 @@ --- name: spa-create-config description: >- - Use when creating or updating splunk_config.yml, designing Splunk Enterprise lab - topology, multisite IDXC, SHC layout, architecture plan before config, or AWS - Terraform block for SPA. Guides interactive design on AWS Linux: deployment intent, - SVA topology, role placement, OS/SSH, spa aws discovery, licenses, - apps, validation. In Cursor invoke with /spa-create-config. + Use when a Splunk lab, pro lab, or Splunk environment is requested on AWS or + VirtualBox, and when creating or updating splunk_config.yml: single-node, + multisite IDXC, SHC layout, architecture plan, Redhat or other OS, CPU and instance + size, or the AWS Terraform block for SPA. Guides interactive design on AWS Linux: + deployment intent, SVA topology, role placement, OS/SSH, spa aws discovery, licenses, + apps, validation. Use spa, not the AWS CLI. In Cursor invoke with /spa-create-config. license: Proprietary compatibility: Requires SPA_HOME (install prefix `${XDG_DATA_HOME:-~/.local/share}/spa`, or a git clone with ansible.cfg and bin/). Env dirs from spa init are valid working directories. Boto3 is required for AWS discovery and suspend/resume. metadata: @@ -32,12 +33,34 @@ Interactive workflow for `config/splunk_config.yml` on AWS. Linux only. ## When NOT to Use -- App-scope test distillation → [spa-add-test-scenario](../spa-add-test-scenario/SKILL.md) +- App-scope test distillation → [tests/README.md](../../../tests/README.md) - Splunkbase search / TA lookup → load [spa-apps](../spa-apps/SKILL.md) (`spa --agent apps search` then one `spa --agent apps add`) - Flat deployment test configs under `tests/configs/*.yml` only - Production sizing / PS engagement (guidance only; no auto-sizing) - Auto-running provision/deploy (user runs `spa provision` / `spa deploy` after validation) +## Entry points + +Use this skill for a new environment or a change to an existing config. Load [/spa](../spa/SKILL.md) for lifecycle (init, validate, provision, deploy, destroy). Route tree: [workflow-decision-tree.md](references/workflow-decision-tree.md). + +- **Brand new environment** ("I need a Splunk lab or environment..."): start here at Phase 0a in plan mode. Init runs at Phase 1, only after the location, name, and summary gate below. Phase 7 writes the config. Show the plan, get approval, write the config, validate, then hand off to `/spa`. +- **Modify existing config** ("add ITSI", "change the OS"): skip the greenfield phases and use "Add / change a setting" below. +- **Lifecycle** ("destroy", "status", "deploy", "logs"): load `/spa` directly. No design is needed. +- **Apps** ("add a TA", "install a content pack"): load `/spa-apps`. + +The design phases exist so the first `--example` template is not accepted blindly: intent (0a), requirements (0b), topology (1), sizing (2), OS (3), AWS (4), counts and placement (5, 5b), apps and licenses (6, 6b), plan (6c), write (7), validate (8), handoff (9). + +**Provider:** `spa init` requires `--provider aws|virtualbox|external` unless the operator has saved a default with `spa environment set --provider NAME`. Ask the operator which provider to use before any init, then record it with `--aws` in the header when it is AWS. See [config-header-best-practices.md](references/config-header-best-practices.md). + +## Never edit by hand (mandatory) + +Never edit `config/splunk_config.yml` yourself: no editor, no shell write, no YAML patch, no pasted block. Every change goes through `spa config`: + +- Read: `spa --agent config search QUERY`, `spa --agent config explain PATH`, `spa --agent config get [PATH]` +- Write: `spa --agent config set PATH VALUE`, `add`, `unset`, `merge --file FILE`, `topology`, `provider`, and `header set` + +App entries go to [spa-apps](../spa-apps/SKILL.md) (`spa --agent apps add`). Validation, provision, deploy, and destroy stay in [/spa](../spa/SKILL.md), which waits for your yes. + ## Secrets — never display credential values (mandatory) Follow [secrets-handling.md](references/secrets-handling.md) for the full list. @@ -78,7 +101,9 @@ Follow [secrets-handling.md](references/secrets-handling.md) for the full list. | Licenses | [references/licenses.md](references/licenses.md) | | RF / SF & sizing | [references/rf-sf-sizing.md](references/rf-sf-sizing.md) | | Validate / deploy | [references/validation.md](references/validation.md) | -| Header template | [assets/config-header-template.md](assets/config-header-template.md) | +| Header fields (set with `spa config header set`) | [assets/config-header-template.md](assets/config-header-template.md) | +| Header best practices | [references/config-header-best-practices.md](references/config-header-best-practices.md) | +| Which skill for which request | [references/workflow-decision-tree.md](references/workflow-decision-tree.md) | | Architecture plan | [assets/architecture-plan-template.md](assets/architecture-plan-template.md) | | Secrets / env vars | [references/secrets-handling.md](references/secrets-handling.md) | @@ -106,7 +131,7 @@ Repo keys: for every requested capability, use this loop. Call `bin/spa` JSON. D 3. Target path: default `config/splunk_config.yml` (for write mode or post-approval). 4. If file exists and **mode=write** (or approving plan): **merge vs overwrite** — AskQuestion if available, otherwise ask in chat, before destructive write. Skip in **plan** mode until Phase 7. 5. **AWS API probe** — `spa aws --check-auth --json` (needs `boto3`; no region required). Record result: - - **Available** → Phase 4 uses API discovery; optional `--splunk-config-aws` at validate. + - **Available** → Phase 4 uses API discovery; optional `spa validate --splunk-config aws` at validate. - **Unavailable** → follow [aws-without-credentials.md](references/aws-without-credentials.md); do not block the workflow. 6. Read existing config if merging or revising an existing plan from prior config. 7. Optional inventory: `spa licenses --json` — note what exists in the shared Software directory ([Prepare Software](../../../docs/user-guide.md#prepare-software)). @@ -135,13 +160,37 @@ Ask scale-out vs scale-up; default **scale out** for Splunk. **Exit:** Short requirements summary for header. +## Environment location, name, and summary (mandatory before init) + +Run this before the first `spa init` or `spa environment init` for a new environment. Skip it when the operator names an existing environment to change (see "Add / change a setting"). + +1. **Gather facts (read-only).** + - Current directory: `pwd`. + - Registered environments: `spa --agent environment list`. Each row has `name`, `path`, `default`, and `exists`. + - If the current directory is the SPA framework checkout (it contains `bin/spa` and `ansible.cfg`) or lies inside `SPA_HOME`, do not recommend it. Recommend the default parent from `spa environment list` instead, and say why. + +2. **Ask the location** with AskQuestion, or in chat if AskQuestion is not available. Offer these options, in this order: + - `/`: **recommended default**. + - The parent folder of each registered environment whose `exists` is true. Show each parent once, with its path. + - A new path that the operator types. + +3. **Recommend a name.** Derive one name from the design so far (topology, OS, sizing), for example `rhel10-single`, `cm-2idx-sh-aws`, or `itsi-lab-vbox`. Use lowercase letters, digits, and hyphens. The folder name is the registry name by default. Use `--name` only when the operator wants a registry name that differs from the folder. The name must not match a `name` in `spa environment list`; if it does, add a suffix. Ask the operator to accept it or type another name. + +4. **Summarize and wait for yes.** Show exactly what will run: + - Name, full path (parent plus name), provider, topology example, and whether the parent is already registered. + - The command: `spa environment init PARENT/NAME --example TOPOLOGY --provider PROVIDER`. Add `--name REGISTRY_NAME` only when the registry name differs from the folder name. + - What it does: creates the folder, scaffolds `config/splunk_config.yml`, and registers the name. + - Wait for an explicit yes in chat. A request in the same message is not the yes. + +5. **Run init only after the yes.** Then continue with the config steps (Phase 2 onward) and the app step (Phase 6). + ## Phase 1 — Topology path **Entry:** Requirements known or skipped for config-test. | Path | Action | |------|--------| -| User knows SVA code | Confirm code → [sva-topology-map.md](references/sva-topology-map.md) then `spa init --example TOPOLOGY --provider aws\|virtualbox ENV` | +| User knows SVA code | Confirm code → [sva-topology-map.md](references/sva-topology-map.md) → the gate above → `spa environment init PARENT/NAME --example TOPOLOGY --provider aws\|virtualbox` | | Needs help | [sva-questionnaire.md](references/sva-questionnaire.md) then `spa --agent config search` | | Config test only | Minimal topology (`single_node` or `cm_2idxc_sh`) | @@ -172,15 +221,15 @@ Set global `os:` block, expected `ssh_username`, and **polkit** (`polkit` on AL/ **With creds:** Run `spa aws --json` — see [aws-baseline.md](references/aws-baseline.md). Agents pass `--json` so the result is JSON. 1. Region (`spa aws --json regions list` or confirm) -2. AMI — if unknown, `spa aws --json --latest-ami --os ` or `spa aws --json --survey` → user picks from `recommended_amis` (preference: RHEL first) -3. `ssh_username` (`spa aws --json --describe-ami`) +2. AMI — if unknown, `spa aws --region REGION --json --latest-ami --os ` or `spa aws --region REGION --json --survey` → user picks from `recommended_amis` (preference: RHEL first). REGION is the Phase 4 region, the same as `terraform.aws.region`; every AMI lookup names it. +3. `ssh_username` (`spa aws --region REGION --json --describe-ami --ami-id AMI_ID`) 4. Instance type (`spa aws --json instance-types list --family t3`; suggest `t3.medium` for config tests) 5. Key pair (`spa aws --json key-pairs list`) 6. Security groups (`spa aws --json security-groups list`) 7. Local: `ssh_private_key_file`, tags, volume size 8. `--validate` before write -**Without creds:** [aws-without-credentials.md](references/aws-without-credentials.md) — static matrix, example AMIs, user-supplied key/SG names; **warn AMIs may be stale**; header note `AWS API: not available`. Do **not** run `--splunk-config-aws`. +**Without creds:** [aws-without-credentials.md](references/aws-without-credentials.md) — static matrix, example AMIs, user-supplied key/SG names; **warn AMIs may be stale**; header note `AWS API: not available`. Do **not** run `spa validate --splunk-config aws`. Cap displayed API results (~10–15 AMIs, ~5 instance types). @@ -195,7 +244,7 @@ Determine roles and node counts (not host mapping yet): - Standalone SH vs SHC (min 3) - MC, LM, DS, HF, UF needed? -Copy `splunk_idxclusters` / `splunk_shclusters` from closest example; apply RF/SF table from topology map when not copying verbatim. +Start from the example chosen at Phase 1 (already scaffolded; do not run init again), then set `splunk_idxclusters` / `splunk_shclusters` counts with `spa --agent config set` or `add`. Apply the RF/SF table from the topology map. **Exit:** Tier counts documented; RF/SF match peer counts per checklist. @@ -261,8 +310,8 @@ Use `spa --agent config explain splunk_defaults.splunk_license_server` or the li **Do not enter** while plan mode is active and plan is still draft. -1. Scaffold with `spa init --example TOPOLOGY --provider aws|virtualbox ENV` (topology + provider only). Do not invent `--addon` or OS flags on init. -2. After the operator confirms, run `spa --agent config header set` (`spa config header set`) to replace the leading comment. Include `--intent`, `--requirements`, `--sva`, `--placement`, `--license`, `--installer`, `--os`, `--aws`, and `--deploy` only when the operator supplied that value. Omitted fields are removed. Pass `--yes`. +1. The environment is already scaffolded by the Phase 1 init after the location, name, and summary gate. Do not run init again here. Do not invent `--addon` or OS flags on init. +2. After the operator confirms, run `spa --agent config header set` (`spa config header set`) to replace the leading comment. Include `--intent`, `--requirements`, `--sva`, `--placement`, `--license`, `--installer`, `--os`, `--aws`, and `--deploy` only when the operator supplied that value. Omitted fields are removed. Pass `--yes`. The field list and examples are in `spa config header set --help`. 3. For OS, SSL, licenses, RF/SF, and other settings: choose with `spa --agent config search QUERY`, then `spa --agent config explain PATH`. Use the one command on that page. That is `spa --agent config set PATH VALUE` for an existing scalar or map, or `spa --agent config add SELECTOR JSON_OBJECT` for a new non-app identity. Use one intentional `spa --agent config merge --file FILE` for a multi-setting fragment. Never paste a feature snippet. For **apps**, load [spa-apps](../spa-apps/SKILL.md), search, and call `spa --agent apps add`. ITSI is app_id 1841, not 5403. 4. Global `terraform.aws` already comes from `--provider aws`; add **`ssh_username`** / AMI from catalog + discovery. 5. Matching global `os:` from selector `os` (not from the topology file). @@ -279,12 +328,12 @@ spa validate --config config/splunk_config.yml This always runs schema validation, inventory load, **license file ↔ license_manager pairing**, and playbook syntax-check. Fix any failure before handoff. -**Without AWS credentials:** default validate above is enough for handoff. **Do not** use `--splunk-config-aws` (it will fail). Note in header; user re-validates with API before provision — see [aws-without-credentials.md](references/aws-without-credentials.md). +**Without AWS credentials:** default validate above is enough for handoff. **Do not** use `spa validate --splunk-config aws` (it will fail). Note in header; user re-validates with API before provision — see [aws-without-credentials.md](references/aws-without-credentials.md). With AWS creds: ```bash -spa validate --splunk-config-aws --config config/splunk_config.yml +spa validate --splunk-config aws --config config/splunk_config.yml ``` Optional: verify license files exist, parse as valid XML, are not expired, and @@ -314,7 +363,7 @@ Pause AWS compute while retaining Terraform state and EBS volumes: `spa suspend --yes`. Resume and refresh changed inventory addresses: `spa resume --yes`. Retained resources continue to incur charges. -Optional: distill app-scope tests via [spa-add-test-scenario](../spa-add-test-scenario/SKILL.md). +App-scope scenarios: [tests/README.md](../../../tests/README.md). ## Terminology diff --git a/skills/spa/spa-create-config/assets/config-header-template.md b/skills/spa/spa-create-config/assets/config-header-template.md index 197ea805..ae218fd9 100644 --- a/skills/spa/spa-create-config/assets/config-header-template.md +++ b/skills/spa/spa-create-config/assets/config-header-template.md @@ -1,17 +1,23 @@ -# Config header template +# Config header fields -Copy into the top of `config/splunk_config.yml` (YAML comments). +Do not paste a header into `config/splunk_config.yml`. Set it with `spa config header set`, which replaces the leading comment and removes any field you omit. The mapping below is the template: each line of the old header is one flag. Run `spa config header set --help` for the same list on the command line. -```yaml ---- -# splunk_config.yml — Splunk Platform Automator -# Intent: config-test | app-lab | production-like -# Requirements: ingest=; users=; use_case=<...>; DR= -# SVA target: (lab compromises: ) -# Role placement: — -# OS: ; ssh_username= -# Deploy: ap ansible/aws_provision.yml -e auto_approve=true; ap ansible/deploy_site.yml -plugin: splunk-platform-automator +| Header line | Flag | Example value | +|-------------|------|---------------| +| Intent | `--intent` | `config-test`, `app-lab`, or `production-like` | +| Requirements | `--requirements` | `ingest=; users=; use_case=...; DR=` | +| SVA target | `--sva` | `S1` (lab compromises: list the gaps) | +| Role placement | `--placement` | `lab-minimal - one host runs indexer and search head` | +| License | `--license` | `trial`, or the license file set in `splunk_defaults` | +| Installer | `--installer` | `Splunk Enterprise Linux tgz from the Software directory` | +| OS | `--os` | `RHEL 10; ssh_username=ec2-user` | +| AWS | `--aws` | `eu-central-1; t3.xlarge; AWS API: not available` (when you skipped the API) | +| Deploy | `--deploy` | `spa provision --yes && spa deploy --yes` | + +Example, with the operator's confirmation first: + +``` +spa --agent config header set --intent config-test --sva S1 --placement "lab-minimal - one host" --os "RHEL 10; ssh_username=ec2-user" --deploy "spa provision --yes && spa deploy --yes" --yes ``` -Adjust lines to match user choices. Remove unused comment lines rather than leaving placeholders. +Agent mode needs `--yes`. Use `--dry-run` to preview. A line break, a certificate or license marker, or a secret-shaped value is refused. Leave out any field the operator did not supply. diff --git a/skills/spa/spa-create-config/references/aws-os-matrix.md b/skills/spa/spa-create-config/references/aws-os-matrix.md index 0e40c035..e9b32243 100644 --- a/skills/spa/spa-create-config/references/aws-os-matrix.md +++ b/skills/spa/spa-create-config/references/aws-os-matrix.md @@ -120,7 +120,7 @@ terraform: ## AMI → ssh_username heuristic -`spa aws --describe-ami` suggests username from AMI name: +`spa aws --region REGION --describe-ami --ami-id AMI_ID` suggests username from AMI name: - Contains `ubuntu` → `ubuntu` - Contains `debian` → `admin` diff --git a/skills/spa/spa-create-config/references/aws-without-credentials.md b/skills/spa/spa-create-config/references/aws-without-credentials.md index a219260a..3885db80 100644 --- a/skills/spa/spa-create-config/references/aws-without-credentials.md +++ b/skills/spa/spa-create-config/references/aws-without-credentials.md @@ -10,7 +10,7 @@ spa aws --check-auth --json | Result | Meaning | Skill path | |--------|---------|------------| -| `ok: true` + `account` | Credentials work | Phase 4 **with creds** (`--survey`, `--latest-ami`, optional `--splunk-config-aws`). Report account ID only — see [secrets-handling.md](secrets-handling.md) | +| `ok: true` + `account` | Credentials work | Phase 4 **with creds** (`--survey`, `--latest-ami`, optional `spa validate --splunk-config aws`). Report account ID only — see [secrets-handling.md](secrets-handling.md) | | `ok: false`, error mentions credentials | No AWS creds / expired session | Phase 4 **without creds** (this doc) | | Exit 2, `boto3 is required` | boto3 not installed | `pip install boto3`; until then, **without creds** path | | `ok: false`, other API error | Profile/region/permission issue | Fix AWS setup or fall back **without creds** and note in header | @@ -36,8 +36,8 @@ Record in config header comment when API was not used: | Command | Without credentials | |---------|---------------------| | `--check-auth` | `ok: false`, error (e.g. Unable to locate credentials) | -| `spa aws regions list`, `--survey`, `--latest-ami`, `--validate` | `ok: false` in JSON or non-zero exit | -| `spa validate --splunk-config-aws` | **Fails** if validate runs and AWS rejects the call — **do not use** without creds | +| `spa aws --region REGION` with `--survey`, `--latest-ami`, or `--validate` (`regions list` needs no region) | `ok: false` in JSON or non-zero exit | +| `spa validate --splunk-config aws` | **Fails** if validate runs and AWS rejects the call — **do not use** without creds | Provision/deploy (`ap ansible/aws_provision.yml`) **requires** working AWS credentials at run time — building config offline is fine, but user must configure creds before provision. @@ -66,9 +66,9 @@ Optional (no AWS): spa validate --check-licenses --config config/splunk_config.yml ``` -**Skip** `--splunk-config-aws` until credentials work. +**Skip** `spa validate --splunk-config aws` until credentials work. -Handoff note: user should run `--check-auth` and `--splunk-config-aws` (or `--validate`) before first provision. +Handoff note: user should run `--check-auth` and `spa validate --splunk-config aws` (or `--validate`) before first provision. ## After credentials become available diff --git a/skills/spa/spa-create-config/references/config-header-best-practices.md b/skills/spa/spa-create-config/references/config-header-best-practices.md new file mode 100644 index 00000000..05bc46df --- /dev/null +++ b/skills/spa/spa-create-config/references/config-header-best-practices.md @@ -0,0 +1,44 @@ +# Config header best practices + +The config header is documentation. It records the design decisions from Phases 0a–9, so a later reader can see why the environment was built this way. Set it with `spa config header set`, not by editing the file. The field list is in `spa config header set --help`, and the value mapping is in [config-header-template.md](../assets/config-header-template.md). + +## When to set the header + +Set it after the operator approves the plan (Phase 6c) and before provisioning: + +``` +spa --agent config header set \ + --intent "config-test" \ + --requirements "single-node system for testing; 4 CPUs; RHEL 10" \ + --sva "S1 - lab single-node topology" \ + --placement "lab-minimal - single host runs indexer and search_head roles" \ + --os "RHEL 10; ssh_username=ec2-user" \ + --aws "eu-central-1; t3.xlarge (4 vCPUs); provider aws (chosen with --provider)" \ + --deploy "spa provision --yes && spa deploy --yes" \ + --yes +``` + +Agent mode needs `--yes`. Use `--dry-run` to preview. Omitted fields are removed, so send the full header each time. + +## Fields + +| Field | Purpose | Example | +| --- | --- | --- | +| `--intent` | Why the environment exists | `config-test`, `app-lab`, `production-like` | +| `--requirements` | Constraints and scale | `4 CPUs; 1TB ingestion; DR=multisite` | +| `--sva` | SVA topology class and lab gaps | `S1 (single-node)`, `M2 (lab gaps: ...)` | +| `--placement` | Where roles run | `lab-minimal`, `separate hosts`, `multisite` | +| `--license` | How Splunk is licensed | `trial`, or the license file in `splunk_defaults` | +| `--installer` | Splunk installer source | `Linux tgz from the Software directory` | +| `--os` | Operating system and SSH user | `RHEL 10; ssh_username=ec2-user` | +| `--aws` | AWS placement and API status | `eu-central-1; t3.xlarge; AWS API not available` | +| `--deploy` | Command that provisions and deploys | `spa provision --yes && spa deploy --yes` | + +## Why it matters + +- **Documentation:** future readers see the design decisions. +- **Audit trail:** the reason for each choice is recorded next to the config. +- **Onboarding:** a new team member sees the intent first. +- **Troubleshooting:** unexpected behavior can be matched against the stated intent and placement. + +Never put a secret, token, password, or license body in a header field. The command refuses secret-shaped values, but do not rely on that check. diff --git a/skills/spa/spa-create-config/references/secrets-handling.md b/skills/spa/spa-create-config/references/secrets-handling.md index 188754ee..2b45fddc 100644 --- a/skills/spa/spa-create-config/references/secrets-handling.md +++ b/skills/spa/spa-create-config/references/secrets-handling.md @@ -17,7 +17,7 @@ Do **not** paste resolved lookup results if a command or playbook prints them. ## AWS API credentials (Phase 4 / validate) -Used by `spa aws`, Terraform provision, and optional `--splunk-config-aws`. +Used by `spa aws`, Terraform provision, and optional `spa validate --splunk-config aws`. | Variable / item | Show in chat? | Notes | |-----------------|---------------|-------| diff --git a/skills/spa/spa-create-config/references/validation.md b/skills/spa/spa-create-config/references/validation.md index 1d216a06..dddae366 100644 --- a/skills/spa/spa-create-config/references/validation.md +++ b/skills/spa/spa-create-config/references/validation.md @@ -40,7 +40,7 @@ spa licenses --config config/splunk_config.yml --json When credentials are available: ```bash -spa validate --splunk-config-aws --config config/splunk_config.yml +spa validate --splunk-config aws --config config/splunk_config.yml ``` Parses `terraform.aws` from the YAML and runs `spa aws --validate`. diff --git a/skills/spa/spa-create-config/references/workflow-decision-tree.md b/skills/spa/spa-create-config/references/workflow-decision-tree.md new file mode 100644 index 00000000..f0564a1e --- /dev/null +++ b/skills/spa/spa-create-config/references/workflow-decision-tree.md @@ -0,0 +1,26 @@ +# Workflow decision tree + +Classify the user message, then load the one skill that owns the next step. Finish that step before loading the next skill. + +``` +User message +├─ New environment or lab ("I need a Splunk environment...") +│ → /spa-create-config, Phases 0a–9, plan mode +│ → plan approved → write config (Phase 7) → spa validate (Phase 8) +│ → /spa for provision and deploy (only after your explicit yes) +├─ Change an existing config ("add ITSI", "change the OS") +│ → /spa-create-config, "Add / change a setting" (skip greenfield phases) +│ → spa validate → /spa only if you ask to deploy +├─ Apps ("add a TA", "Splunkbase", "content pack") +│ → /spa-apps +│ → spa validate → /spa only if you ask to deploy +└─ Lifecycle ("provision", "deploy", "destroy", "status", "logs", "suspend", "resume") + → /spa +``` + +Rules that apply on every branch: + +- Config is changed only with `spa config` commands, never by hand. +- App entries are added only with `spa apps` commands. +- `spa init` runs once, at Phase 1, after the location, name, and summary gate. It does not run on the first message. +- Provision, deploy, destroy, suspend, and resume wait for your yes in chat. diff --git a/skills/spa/spa-create-config/scripts/README.md b/skills/spa/spa-create-config/scripts/README.md index dafef6c4..92e94096 100644 --- a/skills/spa/spa-create-config/scripts/README.md +++ b/skills/spa/spa-create-config/scripts/README.md @@ -4,7 +4,7 @@ Canonical scripts live in the **repository** `bin/` directory. Run from project | Script | Purpose | |--------|---------| -| `spa aws` | AWS discovery: `--check-auth`, `--latest-ami`, `--survey`, `--validate` | +| `spa aws --region REGION` | AWS discovery: `--check-auth`, `--latest-ami`, `--survey`, `--validate` (`regions list` and `--check-auth` need no region) | | `spa licenses` | Scan Software for `.lic` files; propose `splunk_license_file` (ITSI-aware with `--config`) | | `spa validate` | Schema + inventory + playbook syntax-check; optional `--check-licenses` | @@ -12,7 +12,7 @@ Do not duplicate logic in this skill folder. See [references/aws-baseline.md](.. ## SPA skill naming -Canonical skills live under `skills/spa/` ([Agent Skills spec](https://agentskills.io/specification.md)). Install them with `spa skills install`. Use the **`spa-`** prefix (`spa-create-config`, `spa-add-test-scenario`) for `/` command names. +Canonical skills live under `skills/spa/` ([Agent Skills spec](https://agentskills.io/specification.md)). Install them with `spa skills install`. Use the **`spa-`** prefix (`spa-create-config`, `spa-apps`) for `/` command names. ## Dependencies diff --git a/skills/spa/spa/SKILL.md b/skills/spa/spa/SKILL.md index 9010f8b6..8c3eb738 100644 --- a/skills/spa/spa/SKILL.md +++ b/skills/spa/spa/SKILL.md @@ -1,6 +1,13 @@ --- name: spa -description: Load first for Splunk Platform Automator — Software, registered environments, and the spa operator loop. Dispatch YAML/apps/tests to spa-create-config, spa-apps, spa-add-test-scenario. +description: >- + Load first for any request for a Splunk lab, Splunk environment, or Splunk Enterprise + deployment: a Splunk lab, pro lab, single-node, clustered, ITSI, or ES environment on AWS + or VirtualBox, including a Redhat, Rocky, Ubuntu, or Amazon Linux host with a given CPU + count, or Splunk deployed to existing hosts (register them with spa hosts register). Use + it to create, provision, deploy, validate, suspend, resume, destroy, or list environments + and hosts. Use spa, not the AWS CLI, Terraform, or Ansible directly. Dispatch YAML to + spa-create-config and apps to spa-apps. Lifecycle and prerequisites stay in this skill. --- # spa skill @@ -9,15 +16,29 @@ Product entry for SPA (same role as cup’s main skill). **Load `/spa` first.** ## When to use -Anything SPA: shared Software, registered environments, validate / provision / deploy / hosts / run. Task skills apply **after** an env exists, for that env’s YAML, Splunkbase apps, or app-scope tests. +Anything SPA: shared Software, registered environments, validate / provision / deploy / hosts / run. Task skills apply **after** an env exists, for that env’s YAML or Splunkbase apps. + +## Skill selection + +Classify the request first, then load the one skill that owns the next step. Load one skill at a time, in the order the work needs them. For example, `/spa-create-config` hands the write to `/spa` and apps to `/spa-apps`. Route tree: [workflow-decision-tree.md](../spa-create-config/references/workflow-decision-tree.md). + +| Request | Load | Keywords | +| --- | --- | --- | +| New environment or lab | `/spa-create-config` (Phases 0a–9, plan mode) | create, need, set up, new lab | +| Change an existing config | `/spa-create-config` ("Add / change a setting") | add, change, update, enable | +| Lifecycle | `/spa` | provision, deploy, destroy, status, logs, validate, suspend, resume | +| Apps | `/spa-apps` | Splunkbase, TA, app, content pack | +| Deploy to existing hosts | `/spa` (`spa hosts register`, then `spa deploy`) | existing hosts | ## Contract +- **Config is command-only:** never edit `splunk_config.yml` by hand (no editor, no shell write, no YAML patch). Read and change it only with `spa config ...` (`search`, `explain`, `get`, `set`, `add`, `unset`, `merge`, `topology`, `provider`, `header set`). Config design and setting changes go to `/spa-create-config`. App entries go to `/spa-apps`. Both end with `spa --agent validate`; deploy stays here and waits for your yes. +- **Routing:** a request for a Splunk lab, production, staging, or any other Splunk environment (create, deploy, change, check, or tear down), including a deploy to existing hosts (`spa hosts register`, then `spa deploy`), starts here. Do not use the AWS CLI, Terraform, Ansible, or Vagrant to create or change hosts for a Splunk environment. Use `spa`. - **`SPA_HOME`**: framework. **`SPA_ENV_DIR`**: one environment. Call **`bin/spa` only**. Do not import `spa.api`. Do not start a daemon. - Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Never answer `Proceed?`. - Never print secrets. Report env vars as set / not set. YAML: `lookup('env', ...)`. Do not pass `spa -v` (unredacted Ansible stdout). - Full command names (`spa environment list`, `spa hosts ssh`, `spa validate`). Humans may type `spa env`. -- No `ansible-playbook` / raw Vagrant for operators. +- No raw Ansible or Vagrant for operators. Ask in chat and wait before provision, deploy, destroy, suspend, resume, or environment remove. See [lifecycle.md](references/lifecycle.md) and [prerequisites.md](references/prerequisites.md). Discovery: `spa agent schema` / [docs/commands.md](../../../docs/commands.md). All settings: [docs/config.md](../../../docs/config.md) (`spa --no-agent config schema --markdown`). Building `splunk_config.yml` follows the `spa-create-config` loop: discover with `spa --agent config search QUERY` and `spa --agent config explain PATH`. `spa --agent config explain PATH` returns that feature's one mutation command and does not apply it. Update an existing path with `spa --agent config set`, append a new non-app identity with `spa --agent config add SELECTOR JSON_OBJECT`, or send a deliberate fragment with `spa --agent config merge --file FILE`, then run `spa --agent validate`. App entries use `spa --agent apps add`. Catalog snippets are examples and are never applied implicitly. Explain paths omit empty brackets (`splunk_hosts`, `splunk_app_deployment.apps.premium_app`). Mutation selectors such as `splunk_hosts[name=NAME]` keep the identity. In zsh, quote a selector that contains `[]`. Playbooks: `spa --agent run list` then `spa run NAME --help`. Do not guess flags. @@ -25,10 +46,10 @@ Discovery: `spa agent schema` / [docs/commands.md](../../../docs/commands.md). A | Work | Skill / command | | --- | --- | -| Env lifecycle (init, list, set, remove, `--env`, validate, provision, deploy, power, destroy, hosts) | **this skill** (`/spa`) | -| Create or edit **`splunk_config.yml` only** (SVA, AWS YAML, licenses) | `spa-create-config` — not init, not deploy | -| Splunkbase search / snippet / download | `spa-apps` | -| App-scope tests | `spa-add-test-scenario` | +| Env lifecycle (init, list, set, remove, `--env`, validate, provision, deploy, power, destroy, hosts) | **this skill** (`/spa`) — [lifecycle.md](references/lifecycle.md) | +| Prerequisites (doctor, validate, archives, credentials) | **this skill** (`/spa`) — [prerequisites.md](references/prerequisites.md) | +| Config design or any setting change (SVA, AWS, licenses, header, `spa config ...`) | `spa-create-config` — `spa config` only; not init, not deploy | +| Apps: Splunkbase search, add, remove, local archives, content packs | `spa-apps` — `spa apps` only | ## Environments @@ -39,13 +60,14 @@ spa environment list spa environment set --env-dir ~/work/spa-envs # persist default parent (not ~/Splunk-Platform-Automator) spa environment set --software-dir ~/Software --apps-dir ~/labs/apps spa environment set --default my-lab # fallback when cwd does not select an env -spa environment set --provider virtualbox # providers.yml; aws is implicit (no file) -spa environment init --example cm_2idxc_sh my-lab # name-only under default parent +spa environment set --provider virtualbox # saves the default for init in providers.yml (aws, virtualbox, or external) +spa environment init --example cm_2idxc_sh --provider aws my-lab # name-only under default parent; --provider required unless a default is saved spa environment init --env-dir /custom --name my-lab # one-off; does not change default parent spa init /custom/my-lab # same dest as the line above spa env init my-lab --force # adopt an existing env (cwd or default parent) into the list spa --env my-lab validate spa environment remove my-lab --yes # not spa destroy; --force if it looks deployed +spa environment cleanup # lists registry entries whose dir is missing; --yes removes those entries only ``` `--name` is the registry key (default: folder basename). `spa` resolves `--env`, `SPA_ENV_DIR`, the current directory, then the registered default, and applies that environment internally. When the current directory is the environment, run `spa` with no `--env` and do not run `spa environment list`. `spa environment --export` is the canonical form for external tools. `spa env --export` remains the human alias. diff --git a/skills/spa/spa/references/lifecycle.md b/skills/spa/spa/references/lifecycle.md new file mode 100644 index 00000000..2b8d0caa --- /dev/null +++ b/skills/spa/spa/references/lifecycle.md @@ -0,0 +1,11 @@ +# Lifecycle + +Ask in chat and wait before `provision`, `deploy`, `destroy`, `suspend`, `resume`, or `environment remove`. The request in the same message is not the yes. One yes covers provision and deploy when the question named both. `--yes` appears only after that follow-up confirmation. Config `set`, `init`, and `validate` stay ungated. + +The question names the env and the effect. Provision creates the hosts. Deploy installs Splunk on the existing hosts. Destroy removes provider resources and keeps the registered env, `splunk_config.yml`, and state. `environment remove` drops the registry entry and is not destroy. `spa destroy --hosts` does not exist. Do not delete a live host from config and run `spa provision`. + +Mutating commands pass `--env` or run with that env as cwd. If provision or deploy for that env is still running, stop that pid before destroy. + +Phase lines, the provision/deploy/destroy lock, and the stderr sentence catalog are in the entry skill `## Long runs` section. Forward a phase line from stderr. On `ok: true` or `ok: false`, post the result only. Agent stdout is not a run log. Do not open `data.log` unless the user asked to debug a failure. + +`spa hosts check` and `spa hosts check --hosts NAME_OR_ROLE` are the post-provision ping and `splunk_architecture` compare after the hosts exist. They take no AMI, key, or security-group arguments. Run that check before `spa run` when the playbook needs root. It is not the AWS selection check. diff --git a/skills/spa/spa/references/prerequisites.md b/skills/spa/spa/references/prerequisites.md new file mode 100644 index 00000000..fd21c730 --- /dev/null +++ b/skills/spa/spa/references/prerequisites.md @@ -0,0 +1,17 @@ +# Prerequisites + +Run `spa doctor` and `spa validate` before mutating infrastructure. + +A missing local Splunk archive message names the glob, the resolved Software directory, and one download page. Repeat that message. Do not download the file. + +- Enterprise: https://www.splunk.com/en_us/download/splunk-enterprise.html +- Universal Forwarder (Linux tgz and Windows MSI): https://www.splunk.com/en_us/download/universal-forwarder.html + +The local installer stays in the Software directory when `splunk_download.splunk` or `splunk_download.splunkforwarder` is true. The host URL uses that file's basename. The build number is in the filename and cannot be guessed. + +Missing credentials are reported as **set** or **not set**. Repeat the names from `spa validate`. Never print a value. Never suggest `echo`, `printenv`, `env | grep`, or a zsh indirect expansion. + +- Splunkbase, when an app will be downloaded: `SPLUNKBASE_USERNAME` and `SPLUNKBASE_PASSWORD`. +- AWS, when `spa aws --check-auth` fails: prefer `AWS_PROFILE`; otherwise name `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, plus `AWS_SESSION_TOKEN` for temporary credentials. + +`spa validate --splunk-config aws` reads `terraform.aws` from the environment config. `aws` is the only legal value. Skills do not assemble `spa aws --validate` with region, AMI, key, security groups, and instance type when those values are already in the config. `spa aws --validate` remains the low-level command. diff --git a/tests/README.md b/tests/README.md index 56695b7d..0cf78592 100644 --- a/tests/README.md +++ b/tests/README.md @@ -186,6 +186,15 @@ Scenario-based tests for app scope (no SSH or real hosts). Each scenario under ` Requires `ansible-playbook`, `ansible-core`, `jmespath`, and `lxml` in the test venv (installed by the run script). Output: `tests/configs/app_scope/output/_scope.json`. +### Adding an app-scope scenario + +App-scope scenarios live under `tests/configs/app_scope//`. Each has a distilled `splunk_config.yml` and usually `expected_scope.json`. A flat `tests/configs/*.yml` file is a deployment config for `test_deployment.py`, not an app-scope scenario. + +1. Scan existing `# Scenario:` headers under `tests/configs/app_scope/` so the pattern is not already covered. +2. Create `tests/configs/app_scope//splunk_config.yml` with a header comment (scenario name, topology, what it verifies). Distill the source config: keep the active parts, remove vault values and commented blocks, and keep infrastructure minimal. +3. Run `./tests/run_app_scope_scenarios_tests.sh -k "" -v`. After the first run, update the snapshot with `UPDATE_SCOPE_SNAPSHOTS=1 ./tests/run_app_scope_scenarios_tests.sh -k "snapshot and " -v`. Review the diff before committing. +4. Add scenario-specific assertions in `test_app_scope_scenarios.py` when the snapshot is not enough. + ## Running Tests ### Run SPA CLI Release Tests diff --git a/tests/spa_testutil.py b/tests/spa_testutil.py index 08ee5c4e..7ec0adc4 100644 --- a/tests/spa_testutil.py +++ b/tests/spa_testutil.py @@ -150,7 +150,10 @@ def run_spa(args: Sequence[str], env=None, cwd=None, extra_env=None, stdin=None) def run_spa_init(args: Sequence[str], env=None, cwd=None, extra_env=None): + """Run spa init. Tests pass --provider aws unless they pass one, since init requires a provider.""" argv = list(args) if "--skip-doctor" not in argv: argv.insert(0, "--skip-doctor") + if "--provider" not in argv: + argv[0:0] = ["--provider", "aws"] return run_spa(["init", *argv], env=env, cwd=cwd, extra_env=extra_env) diff --git a/tests/test_docs.py b/tests/test_docs.py index 4e068155..dba3d03b 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -366,6 +366,41 @@ def test_create_config_skill_uses_feature_discovery_loop(self): entry = (PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md").read_text(encoding="utf-8") assert "spa --agent config explain PATH" in entry + def test_spa_skill_confirms_before_destroy(self): + skill_root = PROJECT_ROOT / "skills" / "spa" / "spa" + skill = (skill_root / "SKILL.md").read_text(encoding="utf-8") + lifecycle = (skill_root / "references" / "lifecycle.md").read_text( + encoding="utf-8" + ) + prerequisites = (skill_root / "references" / "prerequisites.md").read_text( + encoding="utf-8" + ) + for text in (skill, lifecycle, prerequisites): + assert "ansible-playbook" not in text + assert "Ask in chat and wait" in skill or "Ask in chat and wait" in lifecycle + assert "The request in the same message is not the yes." in lifecycle + assert "keeps the registered env" in lifecycle + assert "splunk_config.yml" in lifecycle + assert "environment remove" in lifecycle + assert "drops the registry entry" in lifecycle + assert "spa-create-config" in skill + assert "spa-apps" in skill + assert "spa-add-test-scenario" not in skill + assert "spa hosts check" in lifecycle + assert "spa hosts check --hosts" in lifecycle + assert "--ami-id" not in lifecycle + assert "https://www.splunk.com/en_us/download/splunk-enterprise.html" in prerequisites + assert ( + "https://www.splunk.com/en_us/download/universal-forwarder.html" + in prerequisites + ) + assert "SPLUNKBASE_USERNAME" in prerequisites + assert "SPLUNKBASE_PASSWORD" in prerequisites + assert "AWS_PROFILE" in prerequisites + assert "AWS_ACCESS_KEY_ID" in prerequisites + assert "set" in prerequisites and "not set" in prerequisites + assert "${!v}" not in prerequisites + def test_entry_skill_waits_on_phase_sentences(self): text = (PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md").read_text(encoding="utf-8") assert "Creating HOST." in text @@ -375,6 +410,29 @@ def test_entry_skill_waits_on_phase_sentences(self): assert "terraform apply" not in text assert "Apply complete" not in text + def test_create_config_asks_location_name_and_summary_before_init(self): + skill = PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md" + text = skill.read_text(encoding="utf-8") + gate_heading = "## Environment location, name, and summary (mandatory before init)" + assert gate_heading in text + gate_start = text.index(gate_heading) + phase1 = text.index("## Phase 1 — Topology path") + assert gate_start < phase1, "the location gate must come before the first init" + gate = text[gate_start:phase1] + # Location: current directory is the recommended default; registered environments are alternatives. + assert "current directory" in gate + assert "spa --agent environment list" in gate + assert "AskQuestion" in gate + # Name: one recommended name, derived from the design, checked against the registry. + assert "recommend" in gate.lower() + # Summary and explicit yes before the init command runs. + assert "summary" in gate.lower() + assert "PARENT/NAME" in gate and "--example" in gate + assert "--name" in gate # optional: only when the registry name differs from the folder + assert "wait" in gate.lower() + # After init, the flow continues with the config and app steps. + assert "Phase 2" in gate or "Phase 6" in gate + def test_create_config_phase7_sets_header_by_command(self): skill = PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md" text = skill.read_text(encoding="utf-8") @@ -384,3 +442,174 @@ def test_create_config_phase7_sets_header_by_command(self): assert phase8 assert "spa config header set" in phase7_body assert "config-header-template.md" not in phase7_body + + def test_config_skills_never_hand_edit_splunk_config(self): + skills = PROJECT_ROOT / "skills" / "spa" + paths = [ + skills / "spa" / "SKILL.md", + skills / "spa-create-config" / "SKILL.md", + skills / "spa-apps" / "SKILL.md", + ] + for path in paths: + text = path.read_text(encoding="utf-8") + assert "never edit" in text.lower(), path.name + assert "spa config" in text, path.name + + agents = (PROJECT_ROOT / "AGENTS.md").read_text(encoding="utf-8") + assert "Do not hand-edit" in agents + assert "spa config" in agents + + def test_config_skills_hand_off_to_each_other(self): + skills = PROJECT_ROOT / "skills" / "spa" + spa = (skills / "spa" / "SKILL.md").read_text(encoding="utf-8") + create = (skills / "spa-create-config" / "SKILL.md").read_text(encoding="utf-8") + apps = (skills / "spa-apps" / "SKILL.md").read_text(encoding="utf-8") + assert "/spa-create-config" in spa and "/spa-apps" in spa + assert "/spa-apps" in create and "/spa" in create + assert "/spa-create-config" in apps and "/spa" in apps + + def test_config_skills_use_the_current_validate_flag(self): + skills_root = PROJECT_ROOT / "skills" / "spa" + for path in skills_root.rglob("*.md"): + text = path.read_text(encoding="utf-8") + assert "--splunk-config-aws" not in text, path + + def test_create_config_header_asset_names_the_command(self): + template = ( + PROJECT_ROOT + / "skills" + / "spa" + / "spa-create-config" + / "assets" + / "config-header-template.md" + ).read_text(encoding="utf-8") + assert "spa --agent config header set" in template + assert "--intent" in template and "--deploy" in template + assert "do not paste" in template.lower() + + def test_entry_points_and_skill_selection_guide(self): + skills = PROJECT_ROOT / "skills" / "spa" + create = (skills / "spa-create-config" / "SKILL.md").read_text(encoding="utf-8") + entry = (skills / "spa" / "SKILL.md").read_text(encoding="utf-8") + refs = skills / "spa-create-config" / "references" + decision = refs / "workflow-decision-tree.md" + best = refs / "config-header-best-practices.md" + + assert "## Entry points" in create + for phrase in ("Brand new environment", "Modify existing config", "Lifecycle"): + assert phrase in create, phrase + assert "workflow-decision-tree.md" in create + assert "config-header-best-practices.md" in create + + assert "## Skill selection" in entry + assert "Load one skill at a time" in entry + assert "Load ONE skill" not in entry + assert "Load ONE skill" not in create + + assert decision.is_file(), decision + tree = decision.read_text(encoding="utf-8") + for route in ("/spa-create-config", "/spa-apps", "/spa"): + assert route in tree, route + + assert best.is_file(), best + header = best.read_text(encoding="utf-8") + assert "spa config header set" in header + assert "--yes" in header + assert "--sva" in header + + def test_init_examples_name_a_provider(self): + """Every operator-facing init example names --provider, since init requires one.""" + pattern = re.compile(r"\b(?:spa|bin/spa) (?:environment |env )?init --example\b") + paths = [ + PROJECT_ROOT / "AGENTS.md", + PROJECT_ROOT / "README.md", + PROJECT_ROOT / "docs" / "user-guide.md", + PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md", + PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md", + ] + for path in paths: + text = path.read_text(encoding="utf-8") + for number, line in enumerate(text.splitlines(), 1): + if pattern.search(line): + assert "--provider" in line, "%s:%d: %s" % (path.name, number, line) + assert "aws is implicit" not in text, path.name + assert "aws is the implicit default" not in text, path.name + + def test_skill_aws_discovery_commands_pass_region(self): + """AMI and survey lookups must search the region the config uses, so every skill line names --region.""" + skills = PROJECT_ROOT / "skills" / "spa" + triggers = ("--latest-ami", "--survey", "--describe-ami", "amis list") + offenders = [] + for path in sorted(skills.rglob("*.md")): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if "spa aws" in line and any(t in line for t in triggers) and "--region" not in line: + offenders.append("%s:%d" % (path.relative_to(PROJECT_ROOT), number)) + assert offenders == [], offenders + + def test_entry_skill_names_environment_cleanup(self): + skill = (PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md").read_text(encoding="utf-8") + line = next(ln for ln in skill.splitlines() if "spa environment cleanup" in ln) + assert "--yes" in line + + def test_skill_descriptions_route_lab_requests_to_spa(self): + import yaml + + def description(path: Path) -> str: + front = path.read_text(encoding="utf-8").split("---", 2)[1] + meta = yaml.safe_load(front) + return " ".join(str(meta["description"]).split()) + + entry = description(PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md") + for phrase in ( + "Splunk lab", + "Splunk environment", + "AWS CLI", + "Redhat", + "single-node", + "provision", + "deploy", + "destroy", + "existing hosts", + "hosts register", + ): + assert phrase in entry, phrase + + body = (PROJECT_ROOT / "skills" / "spa" / "spa" / "SKILL.md").read_text( + encoding="utf-8" + ) + routing = next( + line for line in body.splitlines() if line.startswith("- **Routing:**") + ) + assert "existing hosts" in routing, routing + + config = description( + PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md" + ) + for phrase in ("Splunk lab", "Splunk environment", "AWS", "single-node", "Redhat"): + assert phrase in config, phrase + + agents = (PROJECT_ROOT / "AGENTS.md").read_text(encoding="utf-8") + assert "Splunk lab" in agents + assert "AWS CLI" in agents + + def test_operator_skills_omit_add_test_scenario(self): + readme = (PROJECT_ROOT / "skills" / "spa" / "README.md").read_text( + encoding="utf-8" + ) + agents = (PROJECT_ROOT / "AGENTS.md").read_text(encoding="utf-8") + contributing = (_docs_dir() / "contributing.md").read_text(encoding="utf-8") + for text in (readme, agents, contributing): + assert "spa-create-config" in text + assert "spa-apps" in text + assert "spa-add-test-scenario" not in text + tests_readme = (PROJECT_ROOT / "tests" / "README.md").read_text(encoding="utf-8") + assert "tests/configs/app_scope//" in tests_readme + assert "UPDATE_SCOPE_SNAPSHOTS=1" in tests_readme + assert "./tests/run_app_scope_scenarios_tests.sh" in tests_readme + user_guide = (_docs_dir() / "user-guide.md").read_text(encoding="utf-8") + assert "wait for a yes in chat" in user_guide + assert "spa destroy --hosts" in user_guide + retired = ( + PROJECT_ROOT / "skills" / "spa" / "spa-add-test-scenario" / "SKILL.md" + ) + assert not retired.exists() diff --git a/tests/test_schema.py b/tests/test_schema.py index 624c249e..99aeae77 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2760,6 +2760,28 @@ def test_invalid_host_architecture_rejected(self): assert result.splunk_hosts[0].splunk_architecture == "arm64" +class TestTerraformAwsUnknownKeys: + """terraform.aws holds only known keys. A misspelled key such as ami must not pass silently.""" + + def test_unknown_key_under_terraform_aws_is_rejected(self): + config = { + "plugin": "splunk-platform-automator", + "terraform": {"aws": {"region": "eu-central-1", "ami": "ami-016793dd57ccbf03f"}}, + "splunk_hosts": [{"name": "shidx", "roles": ["indexer", "search_head"]}], + } + with pytest.raises(ConfigValidationError) as exc_info: + validate_config(config) + assert "ami" in str(exc_info.value) + + def test_known_terraform_aws_keys_are_still_accepted(self): + config = { + "plugin": "splunk-platform-automator", + "terraform": {"aws": {"region": "eu-central-1", "ami_id": "ami-016793dd57ccbf03f"}}, + "splunk_hosts": [{"name": "shidx", "roles": ["indexer", "search_head"]}], + } + validate_config(config) + + class TestDeprecatedAwsBlock: """A top-level or per-host aws key is rejected. terraform.aws stays valid.""" diff --git a/tests/test_spa_api.py b/tests/test_spa_api.py index d650547d..22a767b5 100644 --- a/tests/test_spa_api.py +++ b/tests/test_spa_api.py @@ -29,7 +29,7 @@ "env": (), "catalog": ("extra_dir",), "describe_playbook": ("name", "extra_dir"), - "validate": ("config", "check_licenses", "splunk_config_aws", "agent"), + "validate": ("config", "check_licenses", "splunk_config", "agent"), "doctor": ("spa_home", "env_dir", "aws", "virtualbox", "strict"), "skills": ("action", "platform", "name", "all_packages", "confirm", "agent"), "venv": ("action", "environment", "python", "no_install", "confirm", "agent"), @@ -64,6 +64,7 @@ "default", ), "environment_remove": ("name", "confirm", "force"), + "environment_cleanup": ("confirm", "agent"), "config": ( "action", "paths", @@ -331,6 +332,7 @@ def test_session_init_messages(tmp_path, capsys, monkeypatch): str(dest), example="single_node.yml", example_set=True, + provider="aws", skip_doctor=True, ) assert result.ok, result.error diff --git a/tests/test_spa_cli.py b/tests/test_spa_cli.py index 201b911d..c4bd7589 100644 --- a/tests/test_spa_cli.py +++ b/tests/test_spa_cli.py @@ -109,6 +109,15 @@ def test_spa_validate_from_clone_requires_env_dir(): assert "spa init" in combined +def test_validate_splunk_config_rejects_an_unknown_provider(tmp_path): + result = run_spa(["validate", "--splunk-config", "gcp"], cwd=tmp_path) + assert result.returncode == 2 + assert "aws" in result.stderr + help_result = run_spa(["validate", "--help"]) + assert "--splunk-config" in help_result.stdout + assert "--splunk-config-aws" not in help_result.stdout + + def test_shell_help_is_the_shell_parser(): """spa shell -h must reach shell.py, not the spa dispatcher.""" result = run_spa(["shell", "--help"]) @@ -461,7 +470,7 @@ def fake_licenses(argv=None, as_agent=False): seen["licenses"] = as_agent return 0 - def fake_aws(argv=None, as_agent=False): + def fake_aws(argv=None, as_agent=False, environment_region=None): seen["aws"] = as_agent return 0 @@ -1684,6 +1693,47 @@ def test_config_header_flag_value_set_is_not_the_action(): assert "spa config header set:" not in result.stderr +HEADER_FIELD_NAMES = ( + "intent", + "requirements", + "sva", + "placement", + "license", + "installer", + "os", + "aws", + "deploy", +) + + +def test_config_header_help_lists_every_header_field(): + """spa config header --help says which header fields header set accepts.""" + result = run_spa(["--no-agent", "config", "header", "--help"]) + assert result.returncode == 0, result.stdout + result.stderr + for name in HEADER_FIELD_NAMES: + assert "--%s" % name in result.stdout, name + assert "Example:" in result.stdout + assert "spa config header set --intent config-test" in result.stdout + + +def test_config_header_without_action_lists_every_header_field(): + """A bare spa config header names every field, not only --intent.""" + result = run_spa(["--no-agent", "config", "header"]) + assert result.returncode == 2, result.stdout + result.stderr + for name in HEADER_FIELD_NAMES: + assert "--%s" % name in result.stderr, name + + +def test_config_header_set_help_explains_each_header_field(): + """header set --help gives a value example for each header field.""" + result = run_spa(["--no-agent", "config", "header", "set", "--help"]) + assert result.returncode == 0, result.stdout + result.stderr + assert "Header fields" in result.stdout + assert "Omitted fields are removed" in result.stdout + assert "config-test | app-lab | production-like" in result.stdout + assert "Example:" in result.stdout + + def test_human_config_header_set_prompt_names_the_config_file(tmp_path, monkeypatch): """The human prompt says the command changes splunk_config.yml and does not write.""" dest = write_min_env(tmp_path / "env") diff --git a/tests/test_spa_cli_contract.py b/tests/test_spa_cli_contract.py index 1e16c60e..14d1a771 100644 --- a/tests/test_spa_cli_contract.py +++ b/tests/test_spa_cli_contract.py @@ -74,7 +74,7 @@ ), ( ["validate", "--help"], - ["usage: spa validate", "--config", "--check-licenses", "--splunk-config-aws"], + ["usage: spa validate", "--config", "--check-licenses", "--splunk-config"], ), ( ["doctor", "--help"], diff --git a/tests/test_spa_environment.py b/tests/test_spa_environment.py index dad450e8..d59d03c2 100644 --- a/tests/test_spa_environment.py +++ b/tests/test_spa_environment.py @@ -38,7 +38,7 @@ def test_one_off_init_does_not_write_env_dir(tmp_path): "environment", "init", "--skip-doctor", - "--example", + "--provider", "aws", "--example", "single_node.yml", "--env-dir", str(parent), @@ -58,7 +58,7 @@ def test_one_off_init_does_not_write_env_dir(tmp_path): "environment", "init", "--skip-doctor", - "--example", + "--provider", "aws", "--example", "single_node.yml", str(tmp_path / "custom2" / "other"), ], @@ -66,7 +66,7 @@ def test_one_off_init_does_not_write_env_dir(tmp_path): ) assert b.returncode == 0, b.stderr + b.stdout c = run_spa_init( - ["--example", "single_node.yml", str(tmp_path / "custom3" / "third")], + ["--provider", "aws", "--example", "single_node.yml", str(tmp_path / "custom3" / "third")], extra_env=extra, ) assert c.returncode == 0, c.stderr + c.stdout @@ -84,7 +84,7 @@ def test_positional_name_override(tmp_path): [ "init", "--skip-doctor", - "--example", + "--provider", "aws", "--example", "single_node.yml", "--name", "prod", @@ -113,14 +113,15 @@ def test_set_env_dir_then_name_only_init(tmp_path): data = yaml.safe_load((tmp_path / "xdg" / "spa" / "paths.yml").read_text()) assert Path(data["env_dir"]).resolve() == parent.resolve() init = run_spa( - ["environment", "init", "--skip-doctor", "--example", "single_node.yml", "alpha"], + ["environment", "init", "--skip-doctor", "--provider", "aws", "--example", "single_node.yml", "alpha"], extra_env=extra, ) assert init.returncode == 0, init.stderr + init.stdout assert (parent / "alpha" / ".spa.yml").is_file() -def test_set_provider_virtualbox_creates_file_aws_removes(tmp_path): +def test_set_provider_saves_virtualbox_and_aws_defaults(tmp_path): + """Each provider choice is saved, so init can tell an explicit aws from no default.""" extra = _xdg(tmp_path) prov = tmp_path / "xdg" / "spa" / "providers.yml" set_vb = run_spa(["environment", "set", "--provider", "vbox"], extra_env=extra) @@ -130,7 +131,8 @@ def test_set_provider_virtualbox_creates_file_aws_removes(tmp_path): assert body["default"] == "virtualbox" set_aws = run_spa(["environment", "set", "--provider", "aws"], extra_env=extra) assert set_aws.returncode == 0, set_aws.stderr - assert not prov.is_file() + assert prov.is_file() + assert yaml.safe_load(prov.read_text())["default"] == "aws" def test_set_software_paths(tmp_path): @@ -175,7 +177,7 @@ def test_init_seeds_only_missing_global_paths(tmp_path): apps.mkdir() first = run_spa_init( [ - "--example", + "--provider", "aws", "--example", "single_node.yml", "--software-dir", str(software), @@ -196,7 +198,7 @@ def test_init_seeds_only_missing_global_paths(tmp_path): other.mkdir() second = run_spa_init( [ - "--example", + "--provider", "aws", "--example", "single_node.yml", "--software-dir", str(other), @@ -218,7 +220,7 @@ def test_init_persists_discovered_global_paths(tmp_path): software.mkdir() apps.mkdir() result = run_spa_init( - ["--example", "single_node.yml", str(tmp_path / "lab")], + ["--provider", "aws", "--example", "single_node.yml", str(tmp_path / "lab")], extra_env=extra, ) assert result.returncode == 0, result.stderr + result.stdout @@ -256,8 +258,8 @@ def test_set_default_environment(tmp_path): extra = _xdg(tmp_path) one = tmp_path / "one" two = tmp_path / "two" - assert run_spa_init(["--example", "single_node.yml", str(one)], extra_env=extra).returncode == 0 - assert run_spa_init(["--example", "single_node.yml", str(two)], extra_env=extra).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(one)], extra_env=extra).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(two)], extra_env=extra).returncode == 0 result = run_spa(["environment", "set", "--default", "two"], extra_env=extra) assert result.returncode == 0, result.stderr listed = run_spa(["--json", "environment", "list"], extra_env=extra) @@ -281,7 +283,7 @@ def test_set_without_flags_errors(tmp_path): def test_env_dir_without_name_errors(tmp_path): result = run_spa( - ["init", "--skip-doctor", "--example", "single_node.yml", "--env-dir", str(tmp_path / "p")], + ["init", "--skip-doctor", "--provider", "aws", "--example", "single_node.yml", "--env-dir", str(tmp_path / "p")], extra_env=_xdg(tmp_path), ) assert result.returncode != 0 @@ -291,7 +293,7 @@ def test_env_dir_without_name_errors(tmp_path): def test_select_env_by_registry_name(tmp_path): extra = _xdg(tmp_path) dest = tmp_path / "lab1" - assert run_spa_init(["--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 result = run_spa( ["--json", "--env", dest.name, "environment", "list"], extra_env=extra, @@ -307,7 +309,7 @@ def test_select_env_by_registry_name(tmp_path): def test_remove_requires_yes(tmp_path): extra = _xdg(tmp_path) dest = tmp_path / "gone" - assert run_spa_init(["--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 refused = run_spa(["environment", "remove", dest.name], extra_env=extra) assert refused.returncode != 0 assert dest.is_dir() @@ -319,7 +321,7 @@ def test_remove_requires_yes(tmp_path): def test_remove_refuses_deployed_without_force(tmp_path): extra = _xdg(tmp_path) dest = tmp_path / "live" - assert run_spa_init(["--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(dest)], extra_env=extra).returncode == 0 (dest / "inventory" / "hosts").write_text("idx1 ansible_host=10.0.0.1\n", encoding="utf-8") refused = run_spa(["environment", "remove", dest.name, "--yes"], extra_env=extra) assert refused.returncode != 0 @@ -336,7 +338,7 @@ def test_test_runs_never_touch_the_user_registry(tmp_path): user_config = Path(os.environ.get("HOME") or Path.home()).resolve() / ".config" / "spa" ambient = user_environments_yml().resolve() assert user_config not in ambient.parents and ambient.parent != user_config - assert run_spa_init(["--example", "single_node.yml", str(tmp_path / "env")]).returncode == 0 + assert run_spa_init(["--provider", "aws", "--example", "single_node.yml", str(tmp_path / "env")]).returncode == 0 listed = run_spa(["--json", "environment", "list"]) rows = json.loads(listed.stdout)["data"]["environments"] assert [row["name"] for row in rows] == ["env"] @@ -487,8 +489,103 @@ def test_resolve_init_dest_force_prefers_existing_cwd(tmp_path): def test_default_init_provider_is_aws(tmp_path): dest = tmp_path / "aws-default" - result = run_spa_init(["--example", "cm_2idxc_sh", str(dest)], extra_env=_xdg(tmp_path)) + result = run_spa_init(["--provider", "aws", "--example", "cm_2idxc_sh", str(dest)], extra_env=_xdg(tmp_path)) assert result.returncode == 0, result.stderr cfg = yaml.safe_load((dest / "config" / "splunk_config.yml").read_text()) assert "terraform" in cfg assert "virtualbox" not in cfg + + +def _register_pair(tmp_path): + """One env whose directory exists and one registry entry whose directory is gone.""" + from spa import registry + + env = {"XDG_CONFIG_HOME": str(tmp_path / "xdg")} + keep = tmp_path / "labs" / "keep" + keep.mkdir(parents=True) + registry.register_environment("keep", keep, env) + registry.register_environment("gone", tmp_path / "labs" / "gone", env) + return env, keep + + +def _registry_names(tmp_path): + data = yaml.safe_load((tmp_path / "xdg" / "spa" / "environments.yml").read_text()) + return set((data or {}).get("environments", {}).keys()), data + + +def test_cleanup_without_yes_lists_missing_and_removes_nothing(tmp_path): + env, keep = _register_pair(tmp_path) + result = run_spa(["--no-agent", "environment", "cleanup"], extra_env=env) + assert result.returncode == 2, result.stdout + result.stderr + assert "gone" in result.stderr + assert str(tmp_path / "labs" / "gone") in result.stderr + assert "missing: keep" not in result.stderr + names, _ = _registry_names(tmp_path) + assert names == {"keep", "gone"} + assert keep.is_dir() + + +def test_cleanup_agent_without_yes_fails_and_names_missing(tmp_path): + import json + + env, _keep = _register_pair(tmp_path) + result = run_spa(["--agent", "environment", "cleanup"], extra_env=env) + assert result.returncode != 0 + payload = json.loads(result.stdout) + assert payload["ok"] is False + assert "gone" in payload["error"] + assert "data" not in payload + names, _ = _registry_names(tmp_path) + assert names == {"keep", "gone"} + + +def test_cleanup_with_yes_removes_only_missing_and_prints_them(tmp_path): + env, keep = _register_pair(tmp_path) + result = run_spa(["--no-agent", "environment", "cleanup", "--yes"], extra_env=env) + assert result.returncode == 0, result.stdout + result.stderr + assert "Removed gone" in result.stdout + assert str(tmp_path / "labs" / "gone") in result.stdout + names, _ = _registry_names(tmp_path) + assert names == {"keep"} + assert keep.is_dir() + + +def test_cleanup_agent_with_yes_reports_removed_rows(tmp_path): + import json + + env, _keep = _register_pair(tmp_path) + result = run_spa(["--agent", "environment", "cleanup", "--yes"], extra_env=env) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert [row["name"] for row in payload["data"]["removed"]] == ["gone"] + assert payload["data"]["removed"][0]["path"] == str(tmp_path / "labs" / "gone") + + +def test_cleanup_when_nothing_is_missing_says_so(tmp_path): + from spa import registry + + env = {"XDG_CONFIG_HOME": str(tmp_path / "xdg")} + keep = tmp_path / "labs" / "keep" + keep.mkdir(parents=True) + registry.register_environment("keep", keep, env) + result = run_spa(["--no-agent", "environment", "cleanup"], extra_env=env) + assert result.returncode == 0, result.stdout + result.stderr + assert "No missing environments" in result.stdout + + +def test_cleanup_moves_default_off_a_removed_entry(tmp_path): + from spa import registry + + env = {"XDG_CONFIG_HOME": str(tmp_path / "xdg")} + keep = tmp_path / "labs" / "keep" + keep.mkdir(parents=True) + registry.register_environment("keep", keep, env) + registry.register_environment("gone", tmp_path / "labs" / "gone", env) + data = registry.load_environments(env) + data["default"] = "gone" + registry.save_environments(data, env) + result = run_spa(["--no-agent", "environment", "cleanup", "--yes"], extra_env=env) + assert result.returncode == 0, result.stdout + result.stderr + _, data = _registry_names(tmp_path) + assert data["default"] == "keep" diff --git a/tests/test_spa_init.py b/tests/test_spa_init.py index 0690e04f..22e66d14 100644 --- a/tests/test_spa_init.py +++ b/tests/test_spa_init.py @@ -812,6 +812,7 @@ def fake_call(cmd, *args, **kwargs): PROJECT_ROOT, example="single_node.yml", example_set=True, + provider="aws", env_venv=True, ansible="2.17.8", skip_doctor=True, @@ -957,3 +958,77 @@ def test_catalog_topologies_match_sva_shape(): assert _role_count(hosts, "search_head") == 6 assert _role_count(hosts, "deployer") == 2 assert {row["shc_site"] for row in config["splunk_shclusters"]} == {"site0"} + + +def _init_no_provider(dest, **extra_env): + return run_spa( + ["--no-agent", "environment", "init", "--skip-doctor", "--example", "single_node.yml", str(dest)], + extra_env=extra_env, + ) + + +def test_init_requires_provider_when_no_default_is_saved(tmp_path): + """init without --provider and no saved default names the three providers and writes nothing.""" + dest = tmp_path / "lab" + result = _init_no_provider(dest, XDG_CONFIG_HOME=str(tmp_path / "xdg")) + assert result.returncode == 2, result.stdout + result.stderr + assert "--provider" in result.stderr + for name in ("aws", "virtualbox", "external"): + assert name in result.stderr, name + assert "spa environment set --provider NAME" in result.stderr + assert not dest.exists() + + +def test_init_agent_error_requires_provider_without_data(tmp_path): + """Agent mode returns ok false with the provider choice and no data.""" + import json + + dest = tmp_path / "lab" + result = run_spa( + ["--agent", "environment", "init", "--skip-doctor", "--example", "single_node.yml", str(dest)], + extra_env={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}, + ) + assert result.returncode == 2, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["ok"] is False + assert "--provider" in payload["error"] + assert "data" not in payload + assert not dest.exists() + + +def test_init_uses_saved_virtualbox_default(tmp_path): + """A default saved with environment set --provider is used when init omits --provider.""" + xdg = str(tmp_path / "xdg") + saved = run_spa(["environment", "set", "--provider", "virtualbox"], extra_env={"XDG_CONFIG_HOME": xdg}) + assert saved.returncode == 0, saved.stderr + dest = tmp_path / "lab" + result = _init_no_provider(dest, XDG_CONFIG_HOME=xdg) + assert result.returncode == 0, result.stdout + result.stderr + config = (dest / "config" / "splunk_config.yml").read_text(encoding="utf-8") + assert "virtualbox" in config + assert "terraform:" not in config + + +def test_init_uses_saved_aws_default_when_set_explicitly(tmp_path): + """Setting aws explicitly is kept, so init does not ask for --provider again.""" + xdg = str(tmp_path / "xdg") + saved = run_spa(["environment", "set", "--provider", "aws"], extra_env={"XDG_CONFIG_HOME": xdg}) + assert saved.returncode == 0, saved.stderr + assert (tmp_path / "xdg" / "spa" / "providers.yml").is_file() + dest = tmp_path / "lab" + result = _init_no_provider(dest, XDG_CONFIG_HOME=xdg) + assert result.returncode == 0, result.stdout + result.stderr + assert "terraform:" in (dest / "config" / "splunk_config.yml").read_text(encoding="utf-8") + + +def test_explicit_provider_flag_wins_over_saved_default(tmp_path): + xdg = str(tmp_path / "xdg") + run_spa(["environment", "set", "--provider", "virtualbox"], extra_env={"XDG_CONFIG_HOME": xdg}) + dest = tmp_path / "lab" + result = run_spa( + ["--no-agent", "environment", "init", "--skip-doctor", "--example", "single_node.yml", + "--provider", "aws", str(dest)], + extra_env={"XDG_CONFIG_HOME": xdg}, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "terraform:" in (dest / "config" / "splunk_config.yml").read_text(encoding="utf-8") diff --git a/tests/test_spa_install.py b/tests/test_spa_install.py index 5e2b61dc..4b02a8f9 100644 --- a/tests/test_spa_install.py +++ b/tests/test_spa_install.py @@ -129,6 +129,8 @@ def test_install_from_tarball_and_extract_anywhere(tmp_path): str(extract / "bin" / "spa"), "init", "--skip-doctor", + "--provider", + "aws", "--example", "single_node.yml", str(dest), diff --git a/tests/test_splunk_config_aws.py b/tests/test_splunk_config_aws.py index cda23deb..db55bcac 100644 --- a/tests/test_splunk_config_aws.py +++ b/tests/test_splunk_config_aws.py @@ -395,3 +395,38 @@ def test_agent_aws_validate_is_one_ok_per_check(monkeypatch, capsys): detailed = json.loads(capsys.readouterr().out) assert detailed["checks"]["security_groups"]["items"][0]["group_id"] == "sg-lab" assert "IpPermissions" not in json.dumps(detailed) + + +def test_environment_config_region_beats_aws_environment_variables(monkeypatch, capsys, tmp_path): + _aws_config(monkeypatch, tmp_path, "eu-central-1") + monkeypatch.setenv("AWS_REGION", "us-east-1") + seen = {} + + def fake_list(session, region, name_filter=None, owners=None, max_results=15): + seen["region"] = region + return {"ok": True, "region": region, "items": []} + + monkeypatch.setattr(aws, "list_amis", fake_list) + monkeypatch.setattr(aws, "_session", lambda region=None: object()) + + assert aws.main(["amis", "list"], environment_region="eu-west-1") == 0 + captured = capsys.readouterr() + assert seen["region"] == "eu-west-1" + assert "Using region eu-west-1 from terraform.aws.region in the environment config." in captured.err + + assert aws.main(["amis", "list", "--json"], environment_region="eu-west-1") == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["region_source"] == "terraform.aws.region" + + assert aws.main(["amis", "list", "--region", "us-west-2"], environment_region="eu-west-1") == 0 + assert seen["region"] == "us-west-2" + + +def test_environment_config_region_is_read_from_terraform_aws(tmp_path): + config = tmp_path / "splunk_config.yml" + config.write_text("terraform:\n aws:\n region: eu-west-1\n", encoding="utf-8") + assert aws.environment_config_region(config) == "eu-west-1" + + config.write_text("terraform:\n aws:\n ami_id: ami-016793dd57ccbf03f\n", encoding="utf-8") + assert aws.environment_config_region(config) is None + assert aws.environment_config_region(tmp_path / "missing.yml") is None From 2d2f07aaa131d4a1f03be5eb1144e54392775bbc Mon Sep 17 00:00:00 2001 From: Marco Stadler Date: Wed, 28 Oct 2026 16:15:38 +0100 Subject: [PATCH 2/3] Close #67 skill review findings: gate ordering, handoff to /spa, and doc fixes - spa-create-config: write barrier before step 6 and --env target check on every write - Provision and deploy hand off to /spa with a separate chat yes each - spa-apps no longer deploys on a bare request - Replace legacy deploy-aws-terraform runbook with a pointer to the skills - Fix init example without provider, changelog flag rename, gate lists - Add TestReviewFindings67 regression tests Co-authored-by: Cursor --- .agent/workflows/deploy-aws-terraform.md | 131 ++-------- AGENTS.md | 2 +- CHANGELOG.md | 6 + docs/aws.md | 2 +- docs/user-guide.md | 2 +- lib/spa/apps.py | 90 +++++-- lib/spa/aws.py | 14 +- skills/spa/spa-apps/SKILL.md | 17 +- skills/spa/spa-create-config/SKILL.md | 201 ++++++++++----- .../references/architecture-input.md | 43 ++++ .../references/aws-baseline.md | 2 +- .../references/aws-without-credentials.md | 4 +- .../references/workflow-decision-tree.md | 10 +- skills/spa/spa/SKILL.md | 18 +- skills/spa/spa/references/lifecycle.md | 2 +- tests/test_docs.py | 229 +++++++++++++++++- tests/test_spa_apps.py | 87 +++++++ tests/test_splunk_config_aws.py | 27 +++ 18 files changed, 677 insertions(+), 210 deletions(-) create mode 100644 skills/spa/spa-create-config/references/architecture-input.md diff --git a/.agent/workflows/deploy-aws-terraform.md b/.agent/workflows/deploy-aws-terraform.md index dcecd3ca..bee77bc8 100644 --- a/.agent/workflows/deploy-aws-terraform.md +++ b/.agent/workflows/deploy-aws-terraform.md @@ -1,122 +1,25 @@ --- -description: Deploy Splunk infrastructure to AWS using Terraform +description: Legacy pointer. AWS Terraform deploy now runs through the /spa and /spa-create-config skills. --- -# Deploy Splunk Infrastructure to AWS with Terraform +# Deploy Splunk to AWS (legacy workflow) -This workflow provisions AWS infrastructure using Terraform and deploys Splunk. Use **only `spa`** from an environment directory (`SPA_ENV_DIR`). Do not call `ansible-playbook` or `python3 bin/…` here. +This file is a legacy runbook. The skills own the flow now. Agents use `/spa-create-config` to design and write `config/splunk_config.yml`, and `/spa` for every lifecycle command. Do not follow step lists from older copies of this file. -## Prerequisites +## Rules that apply -1. **Environment selection:** use `spa --env NAME`, set a registered default, or `cd` into the env. The installed `spa` launcher is on `PATH`. -2. **Host tools:** `spa doctor` (Terraform for AWS). Ansible collections come from `spa_venv` — do not run `ansible-galaxy` in this workflow. -3. **AWS credentials:** `spa aws --check-auth --json`. Report set/not-set only; never print secret values. -4. **Config:** `config/splunk_config.yml` with `terraform.aws` (security group, key pair, AMI in the target region). +- Use only `spa` from the environment directory (`SPA_ENV_DIR`), or pass `--env NAME`. Do not call `ansible-playbook`, `terraform`, or `python3 bin/…` directly. +- Never edit `config/splunk_config.yml` by hand. Change it with `spa config …`. +- `provision`, `deploy`, `destroy`, `suspend`, `resume`, and `environment remove` each wait for an explicit yes in chat for that run. A request in the same message is not that yes. Then pass `--yes`. +- Do not pass `-v` or `--ansible-output` to `provision`, `deploy`, or `run`. +- Use full command names: `spa hosts ssh `, not `spa sh`. -## Step 1: Configure Infrastructure +## Where each step lives -Edit `config/splunk_config.yml` and add/update the `terraform.aws` section (see examples). Discover AMIs and resources with `spa aws --survey --region --json` when credentials work. - -## Step 2: Preview Infrastructure Changes - -```bash -spa provision --yes -- --tags plan -``` - -This generates `terraform/aws/terraform.tfvars`, runs `terraform init` / `plan`, and does **not** apply. - -## Step 3: Provision Infrastructure - -Ask in chat and wait. Name the env and that provision creates the hosts. The request in the same message is not the yes. After that confirmation: - -```bash -spa provision --yes -``` - -Non-interactive (agents / CI): `spa provision --yes && spa deploy --yes`. `--yes` after the subcommand confirms the action (`-y` is the short form; `spa -y provision` also works). Agents must pass `--yes`; they never answer the human `Proceed? [y/N]` prompt. - -This will: - -1. Generate Terraform configuration -2. Initialize Terraform -3. Show the plan -4. Prompt for confirmation unless `--yes` -5. Create EC2 instances and `inventory/hosts` -6. Wait for SSH by default - -Skip SSH wait: `spa provision --yes -- -e wait_for_ssh=false`. - -## Step 4: Verify Host Readiness (optional) - -```bash -spa run aws_wait_hosts -``` - -SSH, Python, uptime, cloud-init. Success text in the playbook may mention deploy; continue with `spa deploy`. - -## Step 5: Deploy Splunk - -```bash -spa deploy -``` - -## Step 6: Verify Deployment - -Open `$SPA_ENV_DIR/config/index.html`, or: - -```bash -spa run create_linkpage -``` - -SSH: `spa hosts ssh ` or `spa sh ` when the host is in inventory. - -## Pause and Resume - -Stop EC2 compute while retaining Terraform state and EBS volumes: - -```bash -spa suspend --yes -``` - -Resume waits for AWS instance health and rewrites `inventory/hosts` with the -new public DNS names/IPs before returning: - -```bash -spa resume --yes -``` - -This is not a destroy operation, but EBS volumes and other retained resources -continue to incur charges. In agent mode both commands require `--yes`. - -## Troubleshooting - -- `spa doctor` and `spa aws --check-auth --json` first. -- Verbose wait: `spa run aws_wait_hosts -- -v` -- Longer SSH timeout: `spa run aws_wait_hosts -- -e ssh_timeout=600` - -## Cleanup - -```bash -spa destroy -``` - -In agent mode, destroy requires `-y` / `--yes`. This permanently deletes EC2 instances. - -## Quick Reference - -| Command | Purpose | -|---------|---------| -| `spa doctor` | Host prerequisites | -| `spa aws --check-auth --json` | Credential probe (no secret values) | -| `spa provision --yes -- --tags plan` | Preview changes | -| `spa provision --yes && spa deploy --yes` | Provision then deploy (stop if provision fails) | -| `spa provision --yes` | Provision infrastructure only | -| `spa run aws_wait_hosts` | Verify host readiness | -| `spa deploy` | Deploy Splunk (hosts already provisioned); `--hosts` for a subset | -| `spa suspend --yes` | Stop EC2 instances; keep state and disks; optional `--hosts` | -| `spa resume --yes` | Start EC2 instances; wait and refresh inventory; optional `--hosts` | -| `spa run create_linkpage` | Host link page | -| `spa destroy` | Destroy infrastructure | -| `spa hosts list --status` | Inventory names, roles, runtime state | -| `spa hosts ssh ` / `spa sh ` | SSH via inventory | -| `spa run list` | Playbook catalog | +| Step | Owner | +|------|-------| +| Design, AWS settings, and config writes | `/spa-create-config` | +| Credential probe | `spa aws --check-auth --json` (report set / not set only) | +| Preview, provision, host check, deploy, suspend, resume, destroy | `/spa` | +| Host status and SSH | `spa hosts list --status`, `spa hosts ssh ` | +| Logs | `spa logs --last` | diff --git a/AGENTS.md b/AGENTS.md index 73b20496..d9bdc525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ SPA provisions and deploys Splunk Enterprise (AWS Linux + Terraform, or VirtualB - **`SPA_HOME`**: framework (prefix or clone). **`SPA_ENV_DIR`**: one environment. Operator `spa` needs `spa init` first. Path diagram: [user guide](docs/user-guide.md#understand-the-paths). - Call **`bin/spa`** only (`spa agent schema`, `spa --agent config search QUERY`, `spa --agent apps`). Load **`/spa`** first ([skills/spa/spa/SKILL.md](skills/spa/spa/SKILL.md)). Do not import `spa.api`. Do not start a daemon. Flag catalog: [docs/commands.md](docs/commands.md). - Do not hand-edit `splunk_config.yml`. Change it with `spa config ...` (skill `/spa-create-config`) and add apps with `spa apps ...` (skill `/spa-apps`). -- Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Never answer `Proceed?` interactively. +- Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Exception: `/spa-create-config` Phase 9 option A hands off to `/spa`, which runs `provision`, then `deploy`, each only after its own explicit yes in chat. Never answer `Proceed?` interactively. - Never print secrets (Splunkbase, AWS keys, vault). Report env vars as **set / not set**. YAML: `lookup('env', ...)`. Do not pass `-v` on provision/deploy/run (unredacted Ansible stdout). - Full command names (`spa hosts ssh`, `spa validate`), not `spa sh` / `spa val`. - Playbooks: `spa --agent run list` then `spa run NAME --help`. Confirmation: `--yes`. Extra Ansible args: `spa run NAME -- …`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 916459ff..ba412995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ Work on this branch is **3.0** (M1–M3 plus the follow-on issues below). Linux ### Changed +- **Validate flag renamed**: `spa validate --splunk-config-aws` is now `spa validate --splunk-config aws`. The old flag is removed, so scripts that pass it must switch to the new form. + +- **Skill gate for new environments**: `/spa-create-config` no longer writes config until the location, name, and summary gate passes. A new-environment request never selects an existing environment, and a write to a registered environment needs that environment named first. Each config or apps write checks `resolved_env_name` in its response. Provision, deploy, host check, destroy, suspend, resume, and logs now run from `/spa` after Phase 9 option A hands off there. + +- **ITSI search groups results**: `spa apps search itsi` in human mode groups hits into "Core ITSI" (1841), "Content packs (from Library 5391)", "Single content packs", and "Other apps". The library section shows the `content-packs list 5391` and `--content-pack NAME` commands, and each single pack shows its add command and that Core ITSI 1841 is added first when missing. Library 5391 is pinned for ITSI queries, so its row appears even when the page omits it. Agent output and non-ITSI searches are unchanged. + - **terraform.aws rejects unknown keys**: a misspelled key such as `ami` next to `ami_id` now fails validation instead of being ignored. - **AWS discovery uses the environment region**: `spa aws` without `--region` searches `terraform.aws.region` from the environment config before `AWS_REGION`, `AWS_DEFAULT_REGION`, or `~/.aws/config`. The skills pass `--region` on every AMI and survey lookup. diff --git a/docs/aws.md b/docs/aws.md index f619352a..a4ff4dcc 100644 --- a/docs/aws.md +++ b/docs/aws.md @@ -13,7 +13,7 @@ spa aws --survey --region REGION --json spa config explain provider.aws ``` -`spa aws` uses `--region` when you pass it. Otherwise it uses `AWS_REGION`, then `AWS_DEFAULT_REGION`, then `region` in `~/.aws/config` for the active profile. Text output names that source on stderr, and the listing stays on stdout. `spa aws regions list` marks that region. `--json` and `--agent` print only JSON; the same source is `region_source` in that body, and error text is redacted. `spa aws list regions` exits 2 and names `spa aws regions list`. Provision, suspend, resume, and destroy still use `terraform.aws.region` from the environment config. +`spa aws` uses `--region` when you pass it. Otherwise it uses `AWS_REGION`, then `AWS_DEFAULT_REGION`, then `region` in `~/.aws/config` for the active profile. Text output names that source on stderr, and the listing stays on stdout. `spa aws regions list` marks that region. `--json` and `--agent` print only JSON; the same source is `region_source` in that body, and error text is redacted. The body also carries `aws_config_region` and `aws_config_profile`, the `~/.aws/config` region even when an environment config sets `terraform.aws.region` first. `spa aws list regions` exits 2 and names `spa aws regions list`. Provision, suspend, resume, and destroy still use `terraform.aws.region` from the environment config. Configure `terraform.aws` with a region, AMI, EC2 key pair, SSH user/key, security groups, and defaults such as instance type and root volume. Per-host `terraform.aws` overrides global defaults. `iter` can generate numbered hosts. Terraform modules remain in `SPA_HOME`; generated variables and state remain in `SPA_ENV_DIR`. diff --git a/docs/user-guide.md b/docs/user-guide.md index 8c9621ae..daec5d51 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -40,7 +40,7 @@ For an existing server, `spa init --example TOPOLOGY --provider external ENV`, t Keep Splunk installers, Professional Services baseconfig apps, and license files **out of** `SPA_HOME`. Point every env at one shared directory: ```bash -spa init --software-dir ~/Software --example cm_2idxc_sh my-lab +spa init --software-dir ~/Software --example cm_2idxc_sh --provider aws my-lab ``` `spa validate` and `spa deploy` fail if that directory is missing or incomplete. diff --git a/lib/spa/apps.py b/lib/spa/apps.py index 45fa6e41..20b1a06f 100644 --- a/lib/spa/apps.py +++ b/lib/spa/apps.py @@ -49,6 +49,7 @@ # Whole-word query tokens that pin a Splunkbase app_id first (fetch if missing). PINNED_SEARCH = ( ("itsi", ITSI_ID, "app", "premium_itsi"), + ("itsi", CONTENT_LIBRARY_ID, "app", "itsi_content_library"), ("es", ES_ID, "app", "es_not_premium"), ) @@ -350,29 +351,80 @@ def project_hit(app: Dict[str, Any]) -> Optional[Dict[str, Any]]: return row +_ITSI_SECTION_BY_KIND = { + "premium_itsi": "Core ITSI", + "itsi_content_library": "Content packs (from Library 5391)", + "itsi_content_pack_single": "Single content packs", +} +_OTHER_APPS_SECTION = "Other apps" +_SECTION_ORDER = ( + "Core ITSI", + "Content packs (from Library 5391)", + "Single content packs", + _OTHER_APPS_SECTION, +) + + +def _search_row_lines(row: Dict[str, Any]) -> List[str]: + name = row.get("name") or "-" + fields = [ + str(row.get("app_id") or ""), + name, + row.get("type") or "", + row.get("kind") or "", + row.get("version") or "", + row.get("title") or "", + ] + first = " | ".join(fields) + if row.get("playbook"): + first = "%s | playbook" % first + lines = [first] + summary = row.get("summary") or "" + if summary: + indent = " " * (len(str(row.get("app_id") or "")) + len(" | ")) + lines.append("%s%s" % (indent, summary)) + return lines + + +def _section_hint_lines(row: Dict[str, Any]) -> List[str]: + indent = " " * (len(str(row.get("app_id") or "")) + len(" | ")) + kind = str(row.get("kind") or "") + if kind == "itsi_content_library": + return [ + "%sList packs: spa apps content-packs list 5391" % indent, + "%sAdd one: spa apps add 5391 --content-pack NAME" % indent, + ] + if kind == "itsi_content_pack_single": + return [ + "%sAdd: spa apps add %s (adds Core ITSI 1841 first if it is missing)" + % (indent, row.get("app_id") or "") + ] + return [] + + def format_search_text(query: str, hits: Sequence[Dict[str, Any]]) -> str: if not hits: return "No Splunkbase apps matched %r\n" % query - lines = [] - for row in hits: - name = row.get("name") or "-" - fields = [ - str(row.get("app_id") or ""), - name, - row.get("type") or "", - row.get("kind") or "", - row.get("version") or "", - row.get("title") or "", + if not any(str(row.get("kind") or "") in _ITSI_SECTION_BY_KIND for row in hits): + lines: List[str] = [] + for row in hits: + lines.extend(_search_row_lines(row)) + return "\n".join(lines) + "\n" + blocks = [] + for label in _SECTION_ORDER: + section_rows = [ + row + for row in hits + if _ITSI_SECTION_BY_KIND.get(str(row.get("kind") or ""), _OTHER_APPS_SECTION) == label ] - first = " | ".join(fields) - if row.get("playbook"): - first = "%s | playbook" % first - lines.append(first) - summary = row.get("summary") or "" - if summary: - indent = " " * (len(str(row.get("app_id") or "")) + len(" | ")) - lines.append("%s%s" % (indent, summary)) - return "\n".join(lines) + "\n" + if not section_rows: + continue + block = [label] + for row in section_rows: + block.extend(_search_row_lines(row)) + block.extend(_section_hint_lines(row)) + blocks.append("\n".join(block)) + return "\n\n".join(blocks) + "\n" def search( diff --git a/lib/spa/aws.py b/lib/spa/aws.py index 5e691545..9f86440b 100755 --- a/lib/spa/aws.py +++ b/lib/spa/aws.py @@ -1262,8 +1262,20 @@ def execute(args: Any, environment_region: Optional[str] = None): if kind == "regions": resolved = resolve_region(getattr(args, "region", None), environment_region) - data = list_regions(_session(), selected_region=resolved.region) + try: + session = _session() + except Exception as exc: # e.g. botocore ProfileNotFound for a missing AWS_PROFILE; SystemExit from a missing boto3 still exits + from spa.runlog import redact + + data = {"ok": False, "error": redact(str(exc)), "items": []} + else: + data = list_regions(session, selected_region=resolved.region) _stamp_region(data, resolved) + if isinstance(data, dict): + # The region in ~/.aws/config, even when an environment config overrides it. + config_region = _region_from_aws_config() + data["aws_config_region"] = config_region.region + data["aws_config_profile"] = config_region.profile return _aws_result(data) if kind in {"amis", "instance-types", "key-pairs", "security-groups"}: diff --git a/skills/spa/spa-apps/SKILL.md b/skills/spa/spa-apps/SKILL.md index 948a6e55..63a086ff 100644 --- a/skills/spa/spa-apps/SKILL.md +++ b/skills/spa/spa-apps/SKILL.md @@ -35,6 +35,14 @@ Never edit `config/splunk_config.yml` by hand for apps. `spa --agent apps add` a Follow [secrets-handling.md](../spa-create-config/references/secrets-handling.md). Report Splunkbase env vars as **set** / **not set**. Never print passwords. YAML creds stay `lookup('env', ...)`. +**Splunkbase credentials check (before a Splunkbase add or download):** run the set / not-set loop from [secrets-handling.md](../spa-create-config/references/secrets-handling.md). Do not use `env | grep` or `printenv`. If either variable is not set, ask (AskQuestion if available): + +1. **Export them in your shell, then restart the session.** Paste nothing into chat. +2. **Skip Splunkbase apps** and use a local archive in `apps_dir` (step 5), or stay trial-only. +3. **Show the setup docs**: [docs/apps.md](../../../docs/apps.md). + +Re-run the set / not-set loop after the operator says it is done. + `spa apps download` reads `splunk_app_deployment.splunkbase_username` / `splunkbase_password` from `splunk_config.yml` first (a `lookup('env', 'NAME')` value resolves that env var; a vault ref falls back), then `SPLUNKBASE_USERNAME` / `SPLUNKBASE_PASSWORD`. ## Workflow (token-cheap) @@ -42,11 +50,16 @@ Follow [secrets-handling.md](../spa-create-config/references/secrets-handling.md 1. `spa --agent apps search QUERY` with technology keywords. Optional `--type app|addon` and `--kind ta|premium_itsi|itsi_content_library|itsi_content_pack_single|es_not_premium`. An `itsi` query pins **1841** first; `es` pins **263**. Do **not** open splunkbase.com first. 2. Pick one `app_id` from the compact rows (`app_id`, `name`, `title`, `type`, `kind`, `version`, `summary`). No full description on search. Default 10 hits; tighten QUERY if the page is wrong. 3. For a normal app, use one transaction: `spa --agent apps add APP_ID --roles ROLE[,ROLE]`. Add schema-backed fields with repeatable `--set FIELD VALUE`; lists and maps use strict JSON. + - **Choosing roles:** read the hosts with `spa --agent config get splunk_hosts`. `--roles` is required for normal apps and rejected for ITSI and content packs. + - One host with one role: use that role and say so in the add summary. + - One host with several roles, or several hosts: ask with AskQuestion which roles the app deploys to. Offer a checkbox list and "All roles on HOST" per host. Never pick roles silently. The answer becomes `target_roles`. + - Check Splunkbase credentials before a Splunkbase add (see the check below). 4. If search reports curated playbooks, **ask** whether to add the customization. Do not assume yes. If agreed, add `--customize` to the same `apps add` command. Required extra-vars secrets are reported as **set / not set** only. 5. Local source: `spa --agent apps add APP_ID --local --roles ...`. It selects the app folder or archive in `apps_dir`, folder first. Nothing there → download first (`spa apps download APP_ID --extract --yes` for a normal folder-backed app; ITSI/content packs deploy from the archive, so omit `--extract`). A custom local app uses its folder name instead of an app ID. -6. Before selecting from Content Library 5391, run `spa --agent apps content-packs list 5391`. It uses `--archive`, a matching archive in `apps_dir`, an install archive already in the app temp directory, or cached pack `app.conf` files. An install archive is left in place. `--version` selects a release (default `latest`); a newer latest release replaces the `app.conf` cache. An archive this command downloads is not kept. Each row supplies the exact folder name, archive label/description/version, matching SPA automation, and legal add command. Use `--archive PATH` to inspect a local archive without an env or credentials. Content Library 5391 then requires either repeatable `--content-pack NAME` or `--install-all-content-packs`. A recognized single content pack has its own Splunkbase app ID and needs neither library flag. The add command adds missing ITSI 1841 deterministically and reports unresolved topology, license, or Java requirements without a partial write. +6. **Pick the right ID.** `1841` is Core ITSI, the app itself. `5391` is the Content Library, which holds many packs. A single content pack has its own Splunkbase app ID. When the operator wants one pack, use that pack's own ID if it has one. Otherwise use `--content-pack NAME` on 5391. Present the choices in three groups so the choice is visible: "Core ITSI" (app 1841 from `spa --agent apps search itsi`), "Single content packs" (rows from search with `kind` `itsi_content_pack_single`), and "Content packs (Library 5391)". The library row comes from search (5391 is pinned for ITSI queries). Its packs come from `spa --agent apps content-packs list 5391`, not from search. Run that command before selecting from Content Library 5391. It uses `--archive`, a matching archive in `apps_dir`, an install archive already in the app temp directory, or cached pack `app.conf` files. An install archive is left in place. `--version` selects a release (default `latest`); a newer latest release replaces the `app.conf` cache. An archive this command downloads is not kept. Each row supplies the exact folder name, archive label/description/version, matching SPA automation, and legal add command. Use `--archive PATH` to inspect a local archive without an env or credentials. Content Library 5391 then requires either repeatable `--content-pack NAME` or `--install-all-content-packs`. A recognized single content pack has its own Splunkbase app ID and needs neither library flag. The add command adds missing ITSI 1841 deterministically and reports unresolved topology, license, or Java requirements without a partial write. 7. Use `spa --agent apps remove APP_ID_OR_NAME` to remove authored configuration; it does not uninstall deployed files. Use `--content-pack NAME` to remove one nested 5391 pack. -8. `spa --agent validate`. Deploy only if the operator asked (`spa deploy --yes`). Standalone configure: `spa run splunk_apps_playbook_run --help` then `--apps-playbook STEM --hosts ROLE --yes`. Env-dir custom files: `--apps-playbook ancustom/my_custom_playbook` (not listed). +8. **After every add, ask about more apps.** If the add was ITSI 1841, first ask about content packs: run `spa --agent apps content-packs list 5391`, ask which packs to add (include "None"), and add each chosen pack with `spa --agent apps add 5391 --content-pack NAME`. Then ask with AskQuestion: **Add another app?** Yes, add another / No, done. On yes, go back to step 1. On no, show `spa --agent config get splunk_app_deployment` and stop. When spa-create-config called this skill, return to its Phase 6 loop. +9. `spa --agent validate`. Deploy only after the operator's yes in chat, then hand off to [/spa](../spa/SKILL.md), which runs `spa deploy --yes` under its own gate. Standalone configure: `spa run splunk_apps_playbook_run --help` then `--apps-playbook STEM --hosts ROLE --yes`. Env-dir custom files: `--apps-playbook ancustom/my_custom_playbook` (not listed). ITSI / ITE Work → **app_id 1841** (`premium_app: itsi`). Do not use 5403. diff --git a/skills/spa/spa-create-config/SKILL.md b/skills/spa/spa-create-config/SKILL.md index 43daa48b..ba9632c8 100644 --- a/skills/spa/spa-create-config/SKILL.md +++ b/skills/spa/spa-create-config/SKILL.md @@ -37,14 +37,14 @@ Interactive workflow for `config/splunk_config.yml` on AWS. Linux only. - Splunkbase search / TA lookup → load [spa-apps](../spa-apps/SKILL.md) (`spa --agent apps search` then one `spa --agent apps add`) - Flat deployment test configs under `tests/configs/*.yml` only - Production sizing / PS engagement (guidance only; no auto-sizing) -- Auto-running provision/deploy (user runs `spa provision` / `spa deploy` after validation) +- Running provision/deploy without an explicit yes in chat for that run (Phase 9 asks first) ## Entry points Use this skill for a new environment or a change to an existing config. Load [/spa](../spa/SKILL.md) for lifecycle (init, validate, provision, deploy, destroy). Route tree: [workflow-decision-tree.md](references/workflow-decision-tree.md). -- **Brand new environment** ("I need a Splunk lab or environment..."): start here at Phase 0a in plan mode. Init runs at Phase 1, only after the location, name, and summary gate below. Phase 7 writes the config. Show the plan, get approval, write the config, validate, then hand off to `/spa`. -- **Modify existing config** ("add ITSI", "change the OS"): skip the greenfield phases and use "Add / change a setting" below. +- **Brand new environment** ("I need a Splunk lab or environment..."): start here at Phase 0a, even when registered environments already exist. A new-environment request never selects an existing environment. The plan-or-write mode is asked at the gate, after the minimum requirements. Nothing writes until the gate passes (see the write barrier in the gate). Then init, the config writes, validate, and hand off to `/spa`. +- **Modify existing config** ("add ITSI", "change the OS"): skip the greenfield phases and use "Add / change a setting" below. Name the target environment before any write. If the operator did not name one, ask which registered environment to change (AskQuestion), and show its path. - **Lifecycle** ("destroy", "status", "deploy", "logs"): load `/spa` directly. No design is needed. - **Apps** ("add a TA", "install a content pack"): load `/spa-apps`. @@ -124,19 +124,33 @@ Repo keys: for every requested capability, use this loop. Call `bin/spa` JSON. D **Actions:** 1. Confirm **project root** (contains `ansible.cfg`, `bin/`). -2. **Mode** — AskQuestion if available, otherwise ask in chat, unless user already stated intent: +2. **Mode** — not asked here. The plan-or-write question is asked in the gate, after the minimum requirements (topology, provider, OS) are defined and before init. If the user already stated intent, note it now and confirm it at the gate: - **plan** — Phases 0a–6b + architecture plan; **no** `splunk_config.yml` write until user approves. - **write** — Full flow through Phase 7–9 (or continue after approved plan). Infer **plan** from phrases like “design”, “discuss”, “plan architecture”; infer **write** from “create config”, “write yaml”, “approve and write”. -3. Target path: default `config/splunk_config.yml` (for write mode or post-approval). -4. If file exists and **mode=write** (or approving plan): **merge vs overwrite** — AskQuestion if available, otherwise ask in chat, before destructive write. Skip in **plan** mode until Phase 7. +3. Target: the environment chosen at the gate, or the named existing environment under "Add / change a setting". Its config is `/config/splunk_config.yml`. Never assume the current directory or the registry default is the target. +4. If the target config already exists and **mode=write** (or approving plan): **merge vs overwrite** — AskQuestion if available, otherwise ask in chat, before any write. Skip in **plan** mode until Phase 7. 5. **AWS API probe** — `spa aws --check-auth --json` (needs `boto3`; no region required). Record result: - **Available** → Phase 4 uses API discovery; optional `spa validate --splunk-config aws` at validate. - **Unavailable** → follow [aws-without-credentials.md](references/aws-without-credentials.md); do not block the workflow. 6. Read existing config if merging or revising an existing plan from prior config. -7. Optional inventory: `spa licenses --json` — note what exists in the shared Software directory ([Prepare Software](../../../docs/user-guide.md#prepare-software)). +7. Licenses are not scanned here. Phase 6b scans the Software directory once, after the apps decision. +8. **Architecture input (optional).** If the user gives a path to a JSON file with architecture decisions (from another skill or CI), follow [architecture-input.md](references/architecture-input.md): validate it, record each phase's source, and skip the collection phases it covers. Phases 6 and 6b are always asked, even when `confirmed` is true. -**Exit:** **Mode recorded**; target path known; merge policy clear when applicable; **AWS API status recorded**; Software licenses noted if scanned. +**Exit:** Intent noted (mode is confirmed at the gate); target path known; merge policy clear when applicable; **AWS API status recorded**; architecture input validated if one was given. + +## Pre-Phase Checkpoint — user input classification + +After Step 0, classify what the user has already specified. Phases marked "Can skip" need no question when the value is clear. Phases marked "Must reconfirm" are always asked. + +| User Specified | Can Skip | Must Reconfirm | +|---|---|---| +| Topology only | 0b, 5b | 6, 6b | +| Topology + OS | 0b, 3, 5b | 6, 6b | +| Topology + OS + sizing | 0b, 2, 3, 5b | 6, 6b | +| All four (topology, OS, sizing, provider) | 0b, 2, 3, 4, 5b | **6, 6b (MANDATORY)** | + +**MANDATORY:** Phase 6 (apps) and Phase 6b (licenses) are asked every time before Phase 7. Skipping a phase is recorded in the reconfirmation table before Phase 7. ## Phase 0a — Deployment intent @@ -160,37 +174,13 @@ Ask scale-out vs scale-up; default **scale out** for Splunk. **Exit:** Short requirements summary for header. -## Environment location, name, and summary (mandatory before init) - -Run this before the first `spa init` or `spa environment init` for a new environment. Skip it when the operator names an existing environment to change (see "Add / change a setting"). - -1. **Gather facts (read-only).** - - Current directory: `pwd`. - - Registered environments: `spa --agent environment list`. Each row has `name`, `path`, `default`, and `exists`. - - If the current directory is the SPA framework checkout (it contains `bin/spa` and `ansible.cfg`) or lies inside `SPA_HOME`, do not recommend it. Recommend the default parent from `spa environment list` instead, and say why. - -2. **Ask the location** with AskQuestion, or in chat if AskQuestion is not available. Offer these options, in this order: - - `/`: **recommended default**. - - The parent folder of each registered environment whose `exists` is true. Show each parent once, with its path. - - A new path that the operator types. - -3. **Recommend a name.** Derive one name from the design so far (topology, OS, sizing), for example `rhel10-single`, `cm-2idx-sh-aws`, or `itsi-lab-vbox`. Use lowercase letters, digits, and hyphens. The folder name is the registry name by default. Use `--name` only when the operator wants a registry name that differs from the folder. The name must not match a `name` in `spa environment list`; if it does, add a suffix. Ask the operator to accept it or type another name. - -4. **Summarize and wait for yes.** Show exactly what will run: - - Name, full path (parent plus name), provider, topology example, and whether the parent is already registered. - - The command: `spa environment init PARENT/NAME --example TOPOLOGY --provider PROVIDER`. Add `--name REGISTRY_NAME` only when the registry name differs from the folder name. - - What it does: creates the folder, scaffolds `config/splunk_config.yml`, and registers the name. - - Wait for an explicit yes in chat. A request in the same message is not the yes. - -5. **Run init only after the yes.** Then continue with the config steps (Phase 2 onward) and the app step (Phase 6). - ## Phase 1 — Topology path **Entry:** Requirements known or skipped for config-test. | Path | Action | |------|--------| -| User knows SVA code | Confirm code → [sva-topology-map.md](references/sva-topology-map.md) → the gate above → `spa environment init PARENT/NAME --example TOPOLOGY --provider aws\|virtualbox` | +| User knows SVA code | Confirm code → [sva-topology-map.md](references/sva-topology-map.md) → record the topology id. Init runs at the location, name, and summary gate after Phase 3. | | Needs help | [sva-questionnaire.md](references/sva-questionnaire.md) then `spa --agent config search` | | Config test only | Minimal topology (`single_node` or `cm_2idxc_sh`) | @@ -216,11 +206,50 @@ Set global `os:` block, expected `ssh_username`, and **polkit** (`polkit` on AL/ **Exit:** OS family chosen; `os:` template ready. +## Environment location, name, and summary (mandatory before init) + +Run this before the first `spa init` or `spa environment init` for a new environment. Skip it when the operator names an existing environment to change (see "Add / change a setting"). + +**Minimum requirements first:** the topology (Phase 1), the provider, and the OS (Phase 3) must be chosen before this gate. Sizing (Phase 2) and AWS settings (Phase 4) are set after init with `spa config set`, so they do not block the name. + +1. **Mode** — ask plan or write (see Step 0, item 2). Use AskQuestion if available, otherwise ask in chat, unless the user already chose one. Do not ask this before the minimum requirements above are defined. **Plan:** stop here. Every `spa config set|add|merge` and `spa apps add` that Phases 2–6 would run becomes a recorded value in the plan. Steps 2–6 and those writes run at the start of Phase 7, after the plan is approved. **Write:** continue with steps 2–6 now. + +2. **Gather facts (read-only).** + - Current directory: `pwd`. + - Registered environments: `spa --agent environment list`. Each row has `name`, `path`, `default`, and `exists`. + - If the current directory is the SPA framework checkout (it contains `bin/spa` and `ansible.cfg`) or lies inside `SPA_HOME`, do not recommend it. Recommend the default parent from `spa environment list` instead, and say why. + - Existing target: a proposed `PARENT/NAME` whose folder exists, or whose name is already registered, is not a new environment. Do not init it or write to it. Write to it only when the operator names it as the environment to change ("Add / change a setting"), and only after a merge-vs-overwrite answer. + +3. **Ask the location** with AskQuestion, or in chat if AskQuestion is not available. Offer these options, in this order: + - `/`: **recommended default**. + - The parent folder of each registered environment whose `exists` is true. Show each parent once, with its path. + - A new path that the operator types. + +4. **Recommend a name.** Derive one name from topology and OS, for example `rhel10-single`, `cm-2idx-sh-aws`, or `itsi-lab-vbox`. Use lowercase letters, digits, and hyphens. The folder name is the registry name by default. Use `--name` only when the operator wants a registry name that differs from the folder. The name must not match a `name` in `spa environment list`; if it does, add a suffix. Ask the operator to accept it or type another name. + +5. **Summarize and wait for yes.** Show exactly what will run: + - Name, full path (parent plus name), provider, topology example, OS, and whether the parent is already registered. + - The command: `spa environment init PARENT/NAME --example TOPOLOGY --provider PROVIDER`. Add `--name REGISTRY_NAME` only when the registry name differs from the folder name. + - What it does: creates the folder, scaffolds `config/splunk_config.yml`, and registers the name. Every later write goes to this environment only. + - Wait for an explicit yes in chat. A request in the same message is not the yes. + +6. **Run init only after the yes.** Then continue with Phase 4 (AWS settings) and the later config steps, and the app step (Phase 6). + +**Write barrier.** Until step 6 has run, no writing command runs: no `spa init`, `spa environment init`, `spa config set|add|unset|merge|topology|provider|header set`, or `spa apps add|remove`. These stay allowed: `spa --agent config search|explain|get`, `spa aws ...`, `spa --agent environment list`, `spa licenses`, and `spa --agent apps search|snippet`. + +**Target check (every write).** Pass `--env NAME` on each write, or run it from the target directory. Config and apps success envelopes include `resolved_env_name`, `env_dir`, and `config_file`. After each write, confirm they match the step 5 target. If they do not, stop, report the mismatch, and do not run another write. Never write to the registry default by accident. + ## Phase 4 — AWS settings **With creds:** Run `spa aws --json` — see [aws-baseline.md](references/aws-baseline.md). Agents pass `--json` so the result is JSON. -1. Region (`spa aws --json regions list` or confirm) +1. **Region.** Run `spa aws --json regions list` and ask with AskQuestion: + - Option 1 is `aws_config_region` from the output, first, marked as the default, and labelled with its source: "`` (from AWS config, profile ``)". If `aws_config_region` is null, put no region first and say so. + - Then up to four other names from `items`, common regions first (`us-east-1`, `us-west-2`, `eu-central-1`, `ap-southeast-1` when present), and "Other: type a region". + - If `ok` is false, `items` is empty. Still show option 1 from `aws_config_region`, and offer "Other". + - Do not pick the default from `region` or `region_source`. In a new environment, `terraform.aws.region` is the scaffold value from `examples/providers/aws.yml`, not a choice. + - If `aws_config_region` is null, show no default. Ask with no region marked. Never choose a default from your own knowledge (for example `us-east-1`). + - Use the "Recommended" label only on option 1, and only when it came from `aws_config_region`. 2. AMI — if unknown, `spa aws --region REGION --json --latest-ami --os ` or `spa aws --region REGION --json --survey` → user picks from `recommended_amis` (preference: RHEL first). REGION is the Phase 4 region, the same as `terraform.aws.region`; every AMI lookup names it. 3. `ssh_username` (`spa aws --region REGION --json --describe-ami --ami-id AMI_ID`) 4. Instance type (`spa aws --json instance-types list --family t3`; suggest `t3.medium` for config tests) @@ -244,7 +273,7 @@ Determine roles and node counts (not host mapping yet): - Standalone SH vs SHC (min 3) - MC, LM, DS, HF, UF needed? -Start from the example chosen at Phase 1 (already scaffolded; do not run init again), then set `splunk_idxclusters` / `splunk_shclusters` counts with `spa --agent config set` or `add`. Apply the RF/SF table from the topology map. +Start from the example chosen at Phase 1 (scaffolded by init at the gate; do not run init again). Set `splunk_idxclusters` / `splunk_shclusters` counts with `spa --agent config set` or `add`, in write mode after the gate or in plan mode at Phase 7. Apply the RF/SF table from the topology map. **Exit:** Tier counts documented; RF/SF match peer counts per checklist. @@ -258,15 +287,30 @@ Summarize hosts before YAML write. **Exit:** Host ↔ role map; strategy for header. -## Phase 6 — Apps (optional) +**Next:** Phase 6 (apps). Do not skip to 6b, 6c, or 7 until Phase 6 has an answer, even when the user did not mention apps. + +## Phase 6 — Apps (mandatory reconfirmation) + +Always ask, even when the user did not mention apps. Use AskQuestion if available, otherwise ask in chat: + +- **Yes, add apps:** follow [apps-questionnaire.md](references/apps-questionnaire.md) and load [/spa-apps](../spa-apps/SKILL.md) to search and add each app. +- **No, trial-only:** reconfirm once: "Confirmed: no apps for this environment. Continue?" Leave out `splunk_app_deployment` only after that yes. -[apps-questionnaire.md](references/apps-questionnaire.md). Skip entire `splunk_app_deployment` if no. +**Loop (when the answer is yes):** -**Exit:** App block ready or explicitly skipped. +1. Search and add one app with [spa-apps](../spa-apps/SKILL.md) (search, pick the ID and roles, `spa --agent apps add`). +1b. **ITSI (1841) first.** If the app just added is ITSI (1841), ask about content packs before the next question. Run `spa --agent apps content-packs list 5391`, ask with AskQuestion which packs to add (include "None"), and add each chosen pack with `spa --agent apps add 5391 --content-pack NAME`. Then go to step 2. +2. After each add, ask with AskQuestion: **"Add another app?"** Options: Yes, add another; No, done with apps. +3. On yes, repeat from step 1 until the operator answers no. Do not skip the question after any add. +4. On no, show the current list with `spa --agent config get splunk_app_deployment` and confirm it is right. Then move on to Phase 6b. + +The loop ends only when the operator answers no. Apps added in this session go into the reconfirmation table with source "User input (chat)". In plan mode, record each app ID and its roles in the plan. Run `spa --agent apps add` only in Phase 7, after the environment gate. + +**Exit:** App list confirmed in the loop (at least one app added, or trial-only confirmed). ## Phase 6b — Licenses -[licenses.md](references/licenses.md). Run **after Phase 6** so ITSI detection is accurate. +[licenses.md](references/licenses.md). Run **after Phase 6** so ITSI detection is accurate. Always ask, even when no license file is found: a trial-only decision is reconfirmed before Phase 7. ```bash spa licenses --config config/splunk_config.yml --json @@ -275,18 +319,20 @@ spa licenses --config config/splunk_config.yml --json 1. Scan the Software directory for `*.lic` / `*.License` and inspect sanitized parsed metadata: `license_type`, `group_id`, `addons`, `capabilities`, `expires_at`, and `status`. Never expose raw license XML, signatures, or GUIDs. -2. If `proposed_splunk_license_file` is non-empty, ask (AskQuestion if available): add to `splunk_defaults`? (especially for lab / app lab intent). -3. **If user accepts license file** → add `license_manager` role on a host in Phase 5b (typical lab: co-locate on `cm` or dedicated `mc`). +2. Always ask (AskQuestion if available), after showing the scan. This step runs whether or not a file was proposed: + - **Files found:** add the proposed file, pick another from the list, or stay trial-only. + - **No usable file:** stay trial-only, or add a license file to the Software directory before deploy. + Trial-only is reconfirmed once: "Confirmed: trial-only license. Continue?" +3. **If user accepts a license file** → add `license_manager` role on a host in Phase 5b (typical lab: co-locate on `cm` or dedicated `mc`). 4. **ITSI in config** → require parsed `enterprise` + `itsi` capabilities; ensure `license_manager` role (Phase 5b). Do not infer capability from filenames. 5. **Enterprise Security in config** (app ID `263` or exact ES name) → require parsed `enterprise` + `es` capabilities. `premium_app: es` is not supported by the deployment schema; do not add it. 6. **License manager role** → `splunk_license_file` is required (schema). **License file in config** → `license_manager` role is required (schema). 7. Reject configured files that are missing, invalid, expired, or do not collectively satisfy the selected premium apps. Warn for expiration within 30 days or an unknown expiration. -8. **Trial-only labs** → omit both `splunk_license_file` and `license_manager`; do not add license file from Software scan alone. -9. No usable files in Software → warn (trial only or add licenses before deploy). +8. **Trial-only, after the reconfirmation in step 2** → omit both `splunk_license_file` and `license_manager`. Do not add a license file from the Software scan alone. Use `spa --agent config explain splunk_defaults.splunk_license_server` or the licenses result's `proposed_splunk_license_file`, then author it under `splunk_defaults` in Phase 7 with `spa --agent config set`. -**Exit:** License list decided or explicitly skipped; LM + ITSI/ES warnings addressed. +**Exit:** License decision confirmed (a file added, or trial-only after reconfirmation); LM + ITSI/ES warnings addressed. ## Phase 6c — Architecture plan (plan mode) @@ -294,7 +340,12 @@ Use `spa --agent config explain splunk_defaults.splunk_license_server` or the li **Actions:** -1. Fill [architecture-plan-template.md](assets/architecture-plan-template.md) from phase exits (0a–6b). +1. Fill [architecture-plan-template.md](assets/architecture-plan-template.md) from phase exits (0a–6b). The plan must show: + - **Infrastructure:** provider, region, instance type, OS, storage size. + - **Splunk topology:** each host and its roles, and the SVA target. + - **Apps:** the count, and which are premium (ITSI, content packs). + - **License:** the file name, or trial-only. + - **Environment variables:** `SPLUNKBASE_USERNAME`, `SPLUNKBASE_PASSWORD`, and the AWS profile or key variables, each as set / not set only. 2. Present the plan in chat. Offer optional file: `docs/plans/-architecture-plan.md`. 3. End with: *Revise anything, or say **approve and write config** to continue to Phase 7.* @@ -304,14 +355,48 @@ Use `spa --agent config explain splunk_defaults.splunk_license_server` or the li **Do not run Phase 7–9** until user explicitly approves (treat approval as `mode=write` for remainder of session). +## Reconfirmation Checkpoint (before Phase 7) + +Show one table of every decision and where it came from: + +| Phase | Source | Value | Status | +|---|---|---|---| +| 0a Intent | User input (chat) | e.g. Feature / app lab | Confirmed | +| 0b Requirements | Auto-skip or default | e.g. moderate workload | Confirmed | +| 1 Topology | User input (chat) or architecture file | e.g. `single_node` | Confirmed | +| 2 Sizing | User input (chat) or architecture file | e.g. `t3.xlarge` | Confirmed | +| 3 OS | User input (chat) or architecture file | e.g. RHEL 10 | Confirmed | +| 4 Provider | User input (chat) or architecture file | e.g. AWS `eu-central-1` | Confirmed | +| 5b Placement | Auto-skip (fully specified) | e.g. all roles co-located | Confirmed | +| 6 Apps | User input (chat) | none, or app IDs | Reconfirm now if not yet answered | +| 6b Licenses | Software scan | file, or trial-only | Reconfirm now if not yet answered | + +Sources: `User input (chat)`, `Architecture file`, `Auto-skip (fully specified)`, `Default`. Status: `Confirmed` or `Reconfirm now`. + +Answer every `Reconfirm now` row first. Then ask: "Confirm all values and write the config?" Wait for an explicit yes in chat. Phase 7 does not start without it. A request in the same message is not the yes. + ## Phase 7 — Write config -**Entry:** `mode=write` OR user said **approve and write config** (or equivalent). +**Entry:** `mode=write` OR user said **approve and write config** (or equivalent), and the reconfirmation table is confirmed. **Do not enter** while plan mode is active and plan is still draft. -1. The environment is already scaffolded by the Phase 1 init after the location, name, and summary gate. Do not run init again here. Do not invent `--addon` or OS flags on init. -2. After the operator confirms, run `spa --agent config header set` (`spa config header set`) to replace the leading comment. Include `--intent`, `--requirements`, `--sva`, `--placement`, `--license`, `--installer`, `--os`, `--aws`, and `--deploy` only when the operator supplied that value. Omitted fields are removed. Pass `--yes`. The field list and examples are in `spa config header set --help`. +1. **Gate check.** Before the first write, confirm the environment gate passed in this session: step 6 ran init for the target, or the operator named an existing environment under "Add / change a setting". In plan mode, run steps 2–6 here first. If neither holds, STOP and run the gate. Do not run init again here. Do not invent `--addon` or OS flags on init. +2. Header values are captured as each phase closes, not reconstructed at write time: + + | Phase | Header flag | + |---|---| + | 0a Intent | `--intent` | + | 0b and 2 Requirements and sizing | `--requirements` | + | 1 Topology | `--sva` | + | 3 OS | `--os` | + | 4 AWS | `--aws` (API-verified, or the unverified note) | + | 5b Placement | `--placement` | + | 6b License | `--license` | + | Operator-named installer | `--installer` | + | 9 Deploy command | `--deploy` | + + After the operator confirms, run `spa --agent config header set` (`spa config header set`) with those values. Include a flag only when its phase produced a value. Omitted fields are removed, so a missing value is an open question for the operator, not a blank. Pass `--yes`. The field list and examples are in `spa config header set --help`. 3. For OS, SSL, licenses, RF/SF, and other settings: choose with `spa --agent config search QUERY`, then `spa --agent config explain PATH`. Use the one command on that page. That is `spa --agent config set PATH VALUE` for an existing scalar or map, or `spa --agent config add SELECTOR JSON_OBJECT` for a new non-app identity. Use one intentional `spa --agent config merge --file FILE` for a multi-setting fragment. Never paste a feature snippet. For **apps**, load [spa-apps](../spa-apps/SKILL.md), search, and call `spa --agent apps add`. ITSI is app_id 1841, not 5403. 4. Global `terraform.aws` already comes from `--provider aws`; add **`ssh_username`** / AMI from catalog + discovery. 5. Matching global `os:` from selector `os` (not from the topology file). @@ -323,10 +408,10 @@ Use `spa --agent config explain splunk_defaults.splunk_license_server` or the li ## Phase 8 — Validate (quality gate) ```bash -spa validate --config config/splunk_config.yml +spa --agent validate ``` -This always runs schema validation, inventory load, **license file ↔ license_manager pairing**, and playbook syntax-check. Fix any failure before handoff. +Run it from the target environment directory, or pass `--env NAME`. This always runs schema validation, inventory load, **license file ↔ license_manager pairing**, and playbook syntax-check. Fix any failure before handoff. **Without AWS credentials:** default validate above is enough for handoff. **Do not** use `spa validate --splunk-config aws` (it will fail). Note in header; user re-validates with API before provision — see [aws-without-credentials.md](references/aws-without-credentials.md). @@ -347,21 +432,25 @@ Optional: `./tests/run_schema_tests.sh -q` Do not hand off until validation passes. See [validation.md](references/validation.md). +**On failure:** quote the failing check and the config path it names. Then ask (AskQuestion if available): **Fix and retry**, **Show config** (`spa --agent config get PATH`), or **Stop**. After each fix, run `spa --agent validate` again. Never hand off on a failed run. + **Exit:** Scripts exit 0. ## Phase 9 — Handoff -User runs (skill does **not** auto-provision): +Validation passed. Ask which way to continue (AskQuestion if available): + +- **A) Hand off to `/spa`:** load [/spa](../spa/SKILL.md) and run provision, then deploy, under its gates ("Handoff from spa-create-config" there). This skill runs no lifecycle command. +- **B) Show me the command:** print the commands below; you run them. +- **C) Wait:** stop here; you run them later. ```bash spa provision --yes && spa deploy --yes ``` -Destroy: `spa destroy --yes` +Each run waits for its own explicit yes in chat. A request earlier in the chat is not that yes. Provision, deploy, `spa hosts check`, destroy, suspend, resume, and logs belong to `/spa`. Do not run them from this skill. -Pause AWS compute while retaining Terraform state and EBS volumes: -`spa suspend --yes`. Resume and refresh changed inventory addresses: -`spa resume --yes`. Retained resources continue to incur charges. +**B and C:** print the commands and the log command (`spa logs --last`), then stop. App-scope scenarios: [tests/README.md](../../../tests/README.md). @@ -375,7 +464,7 @@ Use consistently: `cluster_manager`, `terraform.aws`, `splunk_hosts`, `plugin: s - [ ] Description third person with trigger terms - [ ] SKILL.md under 500 lines; details in `references/` - [ ] When to Use / When NOT to Use present -- [ ] Step 0 mode (`plan` vs `write`) recorded; plan mode skips YAML until approval +- [ ] Mode (`plan` vs `write`) asked at the gate after the minimum requirements; plan mode skips YAML until approval - [ ] Step 0 and phase exit criteria followed - [ ] `bin/*` invoked from project root - [ ] Secrets: Splunkbase and AWS credential env values never shown in chat or terminal output diff --git a/skills/spa/spa-create-config/references/architecture-input.md b/skills/spa/spa-create-config/references/architecture-input.md new file mode 100644 index 00000000..2b54b5ad --- /dev/null +++ b/skills/spa/spa-create-config/references/architecture-input.md @@ -0,0 +1,43 @@ +# Architecture input (JSON from another skill or CI) + +Use this when the user gives a path to a JSON file with architecture decisions. The file is read in chat. There are no CLI flags for it: the agent reads the file, validates it, and follows the rules below. Every config change still goes through `spa --agent config set`, `add`, or `merge` (see [SKILL.md](../SKILL.md)). + +## Contract + +Each phase is an object with `confirmed` (boolean). Phases can be omitted. + +```json +{ + "metadata": { "source_skill": "architecture-planner", "version": "1.0" }, + "environment": { "name": "rhel10-single" }, + "phase_0a": { "intent": "Feature / app lab", "confirmed": true }, + "phase_0b": { "workload": "moderate", "retention_period": "30-90 days", "confirmed": true }, + "phase_1": { "topology": "single_node", "confirmed": true }, + "phase_2": { "instance_type": "t3.xlarge", "root_volume_size": 50, "confirmed": true }, + "phase_3": { "os": "rhel10", "ssh_username": "ec2-user", "confirmed": true }, + "phase_4": { "provider": "aws", "region": "eu-central-1", "ami": "ami-xxxxxxxx", "confirmed": true }, + "phase_5b": { "placement_strategy": "all-roles-colocated", "confirmed": true }, + "phase_6": { "apps": [], "confirmed": true }, + "phase_6b": { "license_file": "Splunk_Enterprise.lic", "license_manager_host": "shidx", "confirmed": true } +} +``` + +Values are suggestions until checked against `spa --agent config search` and `spa --agent config explain PATH`. Topology ids come from `spa init list`. + +## Processing + +1. **Load and validate.** Read the file. If it is not valid JSON, or a value is not a legal option, show each error with its phase and key, then ask: fix the file and retry, start over, or override the value now. Do not guess a replacement. +2. **Reject credentials.** If any key looks like a secret (`password`, `secret`, `token`, `key`, `credential`), stop and ask the user to remove it. Never print the value. Splunkbase and AWS secrets come from env vars, not from this file. +3. **Record the source.** Every value gets the source `Architecture file` in the reconfirmation table. +4. **Skip the covered collection.** For a phase with `"confirmed": true`, use the value and do not ask again, except for phases 6 and 6b (see below). For `"confirmed": false` or a missing phase, collect it from the user as normal. +5. **Apps and licenses are still asked.** Phases 6 and 6b are asked even when `confirmed` is true. The JSON value is shown as the proposed answer ("Use these apps?", "Use this license?"). This keeps the mandatory reconfirmation from the Pre-Phase Checkpoint. +6. **Reconfirmation table.** Show every value with its source before Phase 7, as described in the Reconfirmation Checkpoint section of [SKILL.md](../SKILL.md). Phase 7 starts only after the explicit yes. + +## Conflicts + +If values conflict with the topology (for example an instance type too small for the topology), show a warning and ask the user to override or keep the value. Do not change it silently. + +## Out of scope + +- Remote URLs and environment-variable input. +- Any `spa` command that imports the file. The file is only read by the skill. diff --git a/skills/spa/spa-create-config/references/aws-baseline.md b/skills/spa/spa-create-config/references/aws-baseline.md index 66b256a8..ebbccaea 100644 --- a/skills/spa/spa-create-config/references/aws-baseline.md +++ b/skills/spa/spa-create-config/references/aws-baseline.md @@ -65,7 +65,7 @@ spa aws --region eu-central-1 --validate \ spa aws --survey --region eu-central-1 --json ``` -`--region` wins. When it is omitted, `spa aws` uses `AWS_REGION`, then `AWS_DEFAULT_REGION`, then `region` in `~/.aws/config` for the active profile. Text output names that source, and `spa aws regions list` marks the region with `selected`. `--json` and `--agent` print only JSON, including `region_source`. Provision and the other lifecycle commands still read `terraform.aws.region` from the environment config. +`--region` wins. When it is omitted, `spa aws` uses `AWS_REGION`, then `AWS_DEFAULT_REGION`, then `region` in `~/.aws/config` for the active profile. Text output names that source, and `spa aws regions list` marks the region with `selected`. `--json` and `--agent` print only JSON, including `region_source`. `regions list --json` also returns `aws_config_region` and `aws_config_profile` from `~/.aws/config`, even when an environment config sets the region first. Provision and the other lifecycle commands still read `terraform.aws.region` from the environment config. ## Prerequisites checklist diff --git a/skills/spa/spa-create-config/references/aws-without-credentials.md b/skills/spa/spa-create-config/references/aws-without-credentials.md index 3885db80..fed9ac18 100644 --- a/skills/spa/spa-create-config/references/aws-without-credentials.md +++ b/skills/spa/spa-create-config/references/aws-without-credentials.md @@ -39,11 +39,11 @@ Record in config header comment when API was not used: | `spa aws --region REGION` with `--survey`, `--latest-ami`, or `--validate` (`regions list` needs no region) | `ok: false` in JSON or non-zero exit | | `spa validate --splunk-config aws` | **Fails** if validate runs and AWS rejects the call — **do not use** without creds | -Provision/deploy (`ap ansible/aws_provision.yml`) **requires** working AWS credentials at run time — building config offline is fine, but user must configure creds before provision. +Provision and deploy (`spa provision --yes`, then `spa deploy --yes`, each after its own chat yes in `/spa`) **require** working AWS credentials at run time — building config offline is fine, but user must configure creds before provision. ## Without-creds Phase 4 workflow -1. **Region** — user states org default (e.g. `eu-central-1`); do not call `spa aws regions list`. +1. **Region** — run `spa aws --json regions list`. It works without credentials: `ok` is false and `items` is empty, but `aws_config_region` and `aws_config_profile` still come from `~/.aws/config`. Ask with AskQuestion: option 1 is `aws_config_region` (marked as the default), then "Other: type a region". If `aws_config_region` is null, show no default. Never choose a region from memory. 2. **OS + SSH** — [aws-os-matrix.md](aws-os-matrix.md) templates (`ec2-user` / `ubuntu` / `admin`). 3. **AMI** — copy from `examples/providers/aws.yml` or catalog `provider.aws`; add comment `# verify AMI in console or re-run spa aws when creds available`. 4. **Instance type** — lab default `t3.medium` ([aws-baseline.md](aws-baseline.md)). diff --git a/skills/spa/spa-create-config/references/workflow-decision-tree.md b/skills/spa/spa-create-config/references/workflow-decision-tree.md index f0564a1e..680f9361 100644 --- a/skills/spa/spa-create-config/references/workflow-decision-tree.md +++ b/skills/spa/spa-create-config/references/workflow-decision-tree.md @@ -6,8 +6,8 @@ Classify the user message, then load the one skill that owns the next step. Fini User message ├─ New environment or lab ("I need a Splunk environment...") │ → /spa-create-config, Phases 0a–9, plan mode -│ → plan approved → write config (Phase 7) → spa validate (Phase 8) -│ → /spa for provision and deploy (only after your explicit yes) +│ → plan approved → environment gate (if not passed) → init → write config (Phase 7) → spa validate (Phase 8) +│ → /spa handoff for provision and deploy (only after your explicit yes for each) ├─ Change an existing config ("add ITSI", "change the OS") │ → /spa-create-config, "Add / change a setting" (skip greenfield phases) │ → spa validate → /spa only if you ask to deploy @@ -22,5 +22,7 @@ Rules that apply on every branch: - Config is changed only with `spa config` commands, never by hand. - App entries are added only with `spa apps` commands. -- `spa init` runs once, at Phase 1, after the location, name, and summary gate. It does not run on the first message. -- Provision, deploy, destroy, suspend, and resume wait for your yes in chat. +- `spa init` runs once per new environment, after the location, name, and summary gate. In write mode the gate runs after Phase 3; in plan mode it runs at the start of Phase 7. It does not run on the first message. +- Nothing writes before the gate passes. Every write names its target environment and checks the `resolved_env_name` in the response. +- A new-environment request never writes to an existing environment. Change one only when the operator names it. +- Provision, deploy, destroy, suspend, resume, environment remove, and environment cleanup wait for your yes in chat. diff --git a/skills/spa/spa/SKILL.md b/skills/spa/spa/SKILL.md index 8c3eb738..019760f3 100644 --- a/skills/spa/spa/SKILL.md +++ b/skills/spa/spa/SKILL.md @@ -35,10 +35,10 @@ Classify the request first, then load the one skill that owns the next step. Loa - **Config is command-only:** never edit `splunk_config.yml` by hand (no editor, no shell write, no YAML patch). Read and change it only with `spa config ...` (`search`, `explain`, `get`, `set`, `add`, `unset`, `merge`, `topology`, `provider`, `header set`). Config design and setting changes go to `/spa-create-config`. App entries go to `/spa-apps`. Both end with `spa --agent validate`; deploy stays here and waits for your yes. - **Routing:** a request for a Splunk lab, production, staging, or any other Splunk environment (create, deploy, change, check, or tear down), including a deploy to existing hosts (`spa hosts register`, then `spa deploy`), starts here. Do not use the AWS CLI, Terraform, Ansible, or Vagrant to create or change hosts for a Splunk environment. Use `spa`. - **`SPA_HOME`**: framework. **`SPA_ENV_DIR`**: one environment. Call **`bin/spa` only**. Do not import `spa.api`. Do not start a daemon. -- Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Never answer `Proceed?`. +- Mutating commands need **`--yes`**. Never auto-run provision, deploy, suspend, resume, or destroy. Exception: `/spa-create-config` Phase 9 option A hands off here, and this skill then runs `provision`, then `deploy`, each only after its own explicit yes in chat. Never answer `Proceed?`. - Never print secrets. Report env vars as set / not set. YAML: `lookup('env', ...)`. Do not pass `spa -v` (unredacted Ansible stdout). - Full command names (`spa environment list`, `spa hosts ssh`, `spa validate`). Humans may type `spa env`. -- No raw Ansible or Vagrant for operators. Ask in chat and wait before provision, deploy, destroy, suspend, resume, or environment remove. See [lifecycle.md](references/lifecycle.md) and [prerequisites.md](references/prerequisites.md). +- No raw Ansible or Vagrant for operators. Ask in chat and wait before provision, deploy, destroy, suspend, resume, environment remove, or environment cleanup. See [lifecycle.md](references/lifecycle.md) and [prerequisites.md](references/prerequisites.md). Discovery: `spa agent schema` / [docs/commands.md](../../../docs/commands.md). All settings: [docs/config.md](../../../docs/config.md) (`spa --no-agent config schema --markdown`). Building `splunk_config.yml` follows the `spa-create-config` loop: discover with `spa --agent config search QUERY` and `spa --agent config explain PATH`. `spa --agent config explain PATH` returns that feature's one mutation command and does not apply it. Update an existing path with `spa --agent config set`, append a new non-app identity with `spa --agent config add SELECTOR JSON_OBJECT`, or send a deliberate fragment with `spa --agent config merge --file FILE`, then run `spa --agent validate`. App entries use `spa --agent apps add`. Catalog snippets are examples and are never applied implicitly. Explain paths omit empty brackets (`splunk_hosts`, `splunk_app_deployment.apps.premium_app`). Mutation selectors such as `splunk_hosts[name=NAME]` keep the identity. In zsh, quote a selector that contains `[]`. Playbooks: `spa --agent run list` then `spa run NAME --help`. Do not guess flags. @@ -94,12 +94,26 @@ spa logs --last Start that command once. Provision, deploy, and destroy share one lock. While one of them is running, the others are refused. The error names the running command, for example `A deploy is already running.` Report that sentence and stop. Do not start one of the other two. The lock ends when that process ends, including when it is aborted. Wait in one call until `"ok":` or the process exits. Do not poll every second, and do not put the sentence list into the wait pattern. If a sentence is already visible on the command, repeat that exact line once. Do not read the terminal file, and do not call other tools. Do not run `spa validate` unless the user asked for it. The heartbeat writes nothing. When the process exits, report the envelope in one sentence. Do not wait on the word `phase` or a Terraform banner. +Cursor wait example: start the command as a background shell, keep its shell id, then call `AwaitShell` once with `pattern` `"ok":` and a long `block_until_ms` (for example 1200000). Do not use shell `sleep`, and do not read the terminal file. If the call returns and the process is still running with no `"ok":`, call it again with the same pattern. The sentences are for reporting only; they are not the wait pattern. + Provision and destroy: `Planning the infrastructure.`, `Creating HOST.`, `HOST is up.`, `Destroying HOST.` Deploy: `Host is prepared.`, `Hosts are already prepared.`, `Downloading Splunk on HOST.`, `Splunk is already downloaded on HOST.`, `Extracting Splunk on HOST.`, `Splunk is already extracted on HOST.`, `Configuring the cluster manager.`, `The cluster manager is already configured.`, `Configuring Splunk.`, `Installing cluster manager apps.`, `Installing ITSI.`, `Installing apps on HOST.` A role's already-configured sentence is printed when that role's own tasks finish with nothing changed. Opening checks do not count, and baseconfig between those tasks does not finish the role. A role play with no matching hosts is silent. `spa run` uses those same sentences, plus `Upgrading indexer HOST.`, `Restarting Splunk on HOST.`, and `Running TITLE.` for any other playbook. `spa deploy` runs an Ansible ping with become for the selected hosts after you confirm and before the playbook, then compares `splunk_architecture` with the machine. The expected value is the host override, then that host's `splunk_environments` entry, then `splunk_defaults`, then `amd64`. `spa hosts check` is that check on its own. `spa deploy --hosts ROLE` includes every config host in that role, including one that is not provisioned yet. `spa provision` does not run the check. Before `spa run` of a playbook that needs root, run `spa hosts check`. `external` hosts are registered with `spa hosts register NAME --ssh-host HOST --user USER --key PATH --yes`, which checks SSH and writes the SSH host, user, and key only in `inventory/hosts`. Add `--ssh-port PORT` when SSH is not on port 22. Add `--ip-addr ADDRESS` when Splunk hosts reach that machine on a different address than SSH. Omit `--ip-addr` to use the SSH host for both. The config only needs `external:`. `spa validate` stays the local config check. `spa deploy` pings with become and compares `splunk_architecture` with the machine before the playbook for AWS, VirtualBox, and external hosts. The expected value is the host override, then that host's `splunk_environments` entry, then `splunk_defaults`, then `amd64`. `spa hosts check` is that check without deploying. Run it before `spa run` when the playbook needs root. Provision and destroy do not create or terminate external hosts. Suspend and resume report that SPA does not own power for those hosts. +## Handoff from spa-create-config + +When a design ends with validation passed and the operator picks option A, run these steps here. Each step waits for its own yes in chat. + +1. **Infrastructure plan (read-only).** `spa --agent config get terraform.aws` and `spa --agent config get splunk_hosts`. List region, instance type, volume size, AMI, and host roles. SPA has no separate Terraform plan preview. +2. **Provision gate.** AskQuestion (or chat): **Provision infrastructure?** Yes / No. On yes, run `spa --agent provision --yes` under Long runs. On `"ok": false`, report the error and stop. +3. **Host check.** Run `spa hosts check`. A failed check stops here. Report it. +4. **Deploy gate.** Show the app names from `spa --agent config get splunk_app_deployment` and the license file from `splunk_defaults` (or trial-only). Report Splunkbase credentials as set / not set only. AskQuestion: **Deploy Splunk software?** Yes / No. +5. On yes, run `spa --agent deploy --yes` under Long runs. Report the final status and the Splunk Web URL from the deploy result. + +Stop at the first failure and report it. Never pass `-v`. From a directory other than the environment, pass `--env NAME`. + ## Anti-patterns Import `spa.api`; answer `Proceed?`; invent playbook names; dump full Ansible logs into the model context (use `spa logs` / `data.log` only when the user asked to debug); put installers inside `SPA_HOME`. diff --git a/skills/spa/spa/references/lifecycle.md b/skills/spa/spa/references/lifecycle.md index 2b8d0caa..651551d8 100644 --- a/skills/spa/spa/references/lifecycle.md +++ b/skills/spa/spa/references/lifecycle.md @@ -1,6 +1,6 @@ # Lifecycle -Ask in chat and wait before `provision`, `deploy`, `destroy`, `suspend`, `resume`, or `environment remove`. The request in the same message is not the yes. One yes covers provision and deploy when the question named both. `--yes` appears only after that follow-up confirmation. Config `set`, `init`, and `validate` stay ungated. +Ask in chat and wait before `provision`, `deploy`, `destroy`, `suspend`, `resume`, `environment remove`, or `environment cleanup`. The request in the same message is not the yes. Provision and deploy each need their own yes, even when one question named both. `--yes` appears only after that follow-up confirmation. Config `set`, `init`, and `validate` stay ungated. The question names the env and the effect. Provision creates the hosts. Deploy installs Splunk on the existing hosts. Destroy removes provider resources and keeps the registered env, `splunk_config.yml`, and state. `environment remove` drops the registry entry and is not destroy. `spa destroy --hosts` does not exist. Do not delete a live host from config and run `spa provision`. diff --git a/tests/test_docs.py b/tests/test_docs.py index dba3d03b..a3198787 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -417,21 +417,27 @@ def test_create_config_asks_location_name_and_summary_before_init(self): assert gate_heading in text gate_start = text.index(gate_heading) phase1 = text.index("## Phase 1 — Topology path") - assert gate_start < phase1, "the location gate must come before the first init" - gate = text[gate_start:phase1] + phase3 = text.index("## Phase 3 — Linux OS") + phase4 = text.index("## Phase 4 — AWS settings") + assert phase1 < phase3 < gate_start < phase4, ( + "the gate comes after topology and OS are chosen, and before init and AWS settings" + ) + assert "spa environment init" not in text[phase1:phase3], "topology and OS must not run init" + gate = text[gate_start:phase4] # Location: current directory is the recommended default; registered environments are alternatives. assert "current directory" in gate assert "spa --agent environment list" in gate assert "AskQuestion" in gate - # Name: one recommended name, derived from the design, checked against the registry. + # Name: one recommended name, derived from topology and OS (both chosen before the gate), checked against the registry. assert "recommend" in gate.lower() + assert "topology and OS" in gate # Summary and explicit yes before the init command runs. assert "summary" in gate.lower() assert "PARENT/NAME" in gate and "--example" in gate assert "--name" in gate # optional: only when the registry name differs from the folder assert "wait" in gate.lower() - # After init, the flow continues with the config and app steps. - assert "Phase 2" in gate or "Phase 6" in gate + # After init, the flow continues with the AWS settings, which set values with spa config set. + assert "Phase 4" in gate def test_create_config_phase7_sets_header_by_command(self): skill = PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md" @@ -443,6 +449,156 @@ def test_create_config_phase7_sets_header_by_command(self): assert "spa config header set" in phase7_body assert "config-header-template.md" not in phase7_body + def test_create_config_apps_and_licenses_are_mandatory_gates(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + assert "## Phase 6 — Apps (optional)" not in text + assert "Skip entire `splunk_app_deployment` if no." not in text + phase6 = text[text.index("## Phase 6 — Apps"):text.index("## Phase 6b")] + assert "AskQuestion" in phase6 + assert "reconfirm" in phase6.lower() + phase6b = text[text.index("## Phase 6b"):text.index("## Phase 6c")] + assert "AskQuestion" in phase6b + assert "reconfirm" in phase6b.lower() + assert "Confirmed: trial-only license. Continue?" in phase6b + assert "If `proposed_splunk_license_file` is non-empty, ask" not in phase6b + assert "No usable files in Software → warn" not in phase6b + assert "spa licenses --config config/splunk_config.yml --json" in phase6b + step0 = text[text.index("## Step 0"):text.index("## Pre-Phase Checkpoint")] + assert "spa licenses" not in step0, "licenses are scanned once, in Phase 6b" + + def test_create_config_classifies_user_input_before_phases(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + assert "## Pre-Phase Checkpoint" in text + step0_end = text.index("## Phase 0a — Deployment intent") + assert text.index("## Step 0") < text.index("## Pre-Phase Checkpoint") < step0_end + block = text[text.index("## Pre-Phase Checkpoint"):step0_end] + assert "| User Specified | Can Skip | Must Reconfirm |" in block + assert "6, 6b" in block + assert "MANDATORY" in block + + def test_create_config_asks_mode_only_after_minimum_requirements(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + step0 = text[text.index("## Step 0"):text.index("## Pre-Phase Checkpoint")] + assert "AskQuestion if available, otherwise ask in chat, unless user already stated intent" not in step0, ( + "the plan-or-write question must wait for the minimum requirements" + ) + gate_start = text.index("## Environment location, name, and summary (mandatory before init)") + phase4 = text.index("## Phase 4 — AWS settings") + gate = text[gate_start:phase4] + assert "**Minimum requirements first:**" in gate + assert "**Mode**" in gate, "the mode question is asked in the gate, before init" + assert gate.index("**Minimum requirements first:**") < gate.index("**Mode**") < gate.index("**Ask the location**"), ( + "minimum requirements, then mode, then location" + ) + + def test_create_config_shows_reconfirmation_table_before_write(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + start = text.index("## Reconfirmation Checkpoint") + phase7 = text.index("## Phase 7 — Write config") + assert start < phase7 + block = text[start:phase7] + assert "| Phase | Source | Value | Status |" in block + for row in ("2 Sizing", "3 OS", "4 Provider", "5b Placement"): + assert row in block, row + assert "explicit yes" in block.lower() + + def test_create_config_phase9_asks_before_running_provision_and_deploy(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + phase9 = text[text.index("## Phase 9 — Handoff"):] + assert "spa provision --yes && spa deploy --yes" in phase9 + assert "Show me the command" in phase9 or "print" in phase9.lower() + assert "explicit yes" in phase9 + assert "does **not** auto-provision" not in phase9 + assert "- Auto-running provision/deploy" not in text + agents = (PROJECT_ROOT / "AGENTS.md").read_text(encoding="utf-8") + assert "spa-create-config" in agents and "Phase 9" in agents + + def test_create_config_accepts_architecture_input_as_json_file(self): + skill_dir = PROJECT_ROOT / "skills" / "spa" / "spa-create-config" + create = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + ref = skill_dir / "references" / "architecture-input.md" + assert ref.is_file(), ref + assert "references/architecture-input.md" in create + assert "--architecture-file" not in create + assert "--architecture-stdin" not in create + contract = ref.read_text(encoding="utf-8") + for key in ('"confirmed"', '"phase_1"', '"phase_2"', '"phase_3"', '"phase_4"', '"phase_6"', '"phase_6b"'): + assert key in contract, key + assert "still asked" in contract.lower() + assert "secret" in contract.lower() + + def test_create_config_region_question_puts_aws_config_region_first(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + phase4 = text[text.index("## Phase 4 — AWS settings"):text.index("## Phase 5 — Topology counts")] + assert "spa aws --json regions list" in phase4 + assert "aws_config_region" in phase4 + assert "aws_config_profile" in phase4 + assert "first" in phase4.lower() + assert "from AWS config" in phase4 + assert "AskQuestion" in phase4 + assert "scaffold" in phase4 + + def test_create_config_apps_loop_asks_for_more_after_each_add(self): + skills = PROJECT_ROOT / "skills" / "spa" + create = (skills / "spa-create-config" / "SKILL.md").read_text(encoding="utf-8") + phase6 = create[create.index("## Phase 6 — Apps"):create.index("## Phase 6b")] + assert "Add another app?" in phase6 + assert "after each" in phase6.lower() + assert "until" in phase6.lower() and "no" in phase6.lower() + assert "spa --agent config get splunk_app_deployment" in phase6 + assert "reconfirm" in phase6.lower() + + apps = (skills / "spa-apps" / "SKILL.md").read_text(encoding="utf-8") + workflow = apps[apps.index("## Workflow"):apps.index("ITSI / ITE Work")] + assert "Add another app?" in workflow + assert "after every add" in workflow.lower() + + def test_create_config_asks_content_packs_after_itsi_before_more_apps(self): + skills = PROJECT_ROOT / "skills" / "spa" + create = (skills / "spa-create-config" / "SKILL.md").read_text(encoding="utf-8") + phase6 = create[create.index("## Phase 6 — Apps"):create.index("## Phase 6b")] + loop = phase6[phase6.index("**Loop"):] + assert "ITSI (1841)" in loop + assert "content-packs list 5391" in loop + assert loop.index("content-packs list 5391") < loop.index("Add another app?"), ( + "after ITSI, content packs are asked before the add-another question" + ) + + apps = (skills / "spa-apps" / "SKILL.md").read_text(encoding="utf-8") + step8 = apps[apps.index("8. **After every add"):apps.index("9. `spa --agent validate`")] + assert "1841" in step8 + assert "content-packs list 5391" in step8 + assert step8.index("content-packs list 5391") < step8.index("Add another app?") + + def test_region_default_never_comes_from_agent_knowledge(self): + skills = PROJECT_ROOT / "skills" / "spa" / "spa-create-config" + create = (skills / "SKILL.md").read_text(encoding="utf-8") + phase4 = create[create.index("## Phase 4 — AWS settings"):create.index("## Phase 5 — Topology counts")] + assert "from your own knowledge" in phase4 + assert "null" in phase4 + without_creds = (skills / "references" / "aws-without-credentials.md").read_text(encoding="utf-8") + assert "do not call `spa aws regions list`" not in without_creds + assert "aws_config_region" in without_creds + assert "ok: false" in without_creds + + def test_create_config_skill_stays_under_500_lines(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-create-config" / "SKILL.md").read_text( + encoding="utf-8" + ) + assert len(text.splitlines()) < 500 + def test_config_skills_never_hand_edit_splunk_config(self): skills = PROJECT_ROOT / "skills" / "spa" paths = [ @@ -613,3 +769,66 @@ def test_operator_skills_omit_add_test_scenario(self): PROJECT_ROOT / "skills" / "spa" / "spa-add-test-scenario" / "SKILL.md" ) assert not retired.exists() + + +def _operator_text_paths(): + """Every markdown file an operator or agent reads for commands.""" + paths = [PROJECT_ROOT / "README.md", PROJECT_ROOT / "AGENTS.md"] + paths += sorted((_docs_dir()).glob("*.md")) + paths += sorted((PROJECT_ROOT / "skills").rglob("*.md")) + paths += sorted((PROJECT_ROOT / ".agent").rglob("*.md")) + return paths + + +class TestReviewFindings67: + """Contracts from the #67 completion review. Each was red before its fix.""" + + def test_every_operator_init_example_names_a_provider_even_with_flags_before_it(self): + offenders = [] + pattern = re.compile(r"\bspa (?:environment |env )?init\b") + for path in _operator_text_paths(): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + # Prose that names the flag or negates a topology (not a command) is not an operator example. + negated = "not `spa init" in line or "not a spa init" in line or "`--example`" in line + if pattern.search(line) and "--example" in line and "--provider" not in line and not negated: + offenders.append("%s:%d: %s" % (path.relative_to(PROJECT_ROOT), number, line.strip())) + assert offenders == [], offenders + + def test_spa_apps_hands_deploy_to_the_chat_gate_not_a_bare_command(self): + text = (PROJECT_ROOT / "skills" / "spa" / "spa-apps" / "SKILL.md").read_text(encoding="utf-8") + assert "(`spa deploy --yes`)" not in text + assert "Deploy only after the operator's yes in chat" in text + + def test_no_agent_facing_file_uses_a_non_existent_playbook_or_typo_command(self): + offenders = [] + for path in _operator_text_paths(): + if "ap ansible/" in path.read_text(encoding="utf-8"): + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert offenders == [], offenders + + def test_legacy_aws_runbook_points_to_the_skills_and_drops_the_unsafe_steps(self): + path = PROJECT_ROOT / ".agent" / "workflows" / "deploy-aws-terraform.md" + text = path.read_text(encoding="utf-8") + head = "\n".join(text.splitlines()[:6]) + assert "/spa" in head and "legacy" in head.lower(), head + assert "spa sh " not in text + assert "-- -v" not in text + assert "Edit `config/splunk_config.yml`" not in text + assert "Non-interactive (agents / CI)" not in text + + def test_every_gate_list_names_environment_cleanup_as_well_as_remove(self): + skills = PROJECT_ROOT / "skills" / "spa" + for path in ( + skills / "spa" / "references" / "lifecycle.md", + skills / "spa" / "SKILL.md", + skills / "spa-create-config" / "references" / "workflow-decision-tree.md", + ): + text = path.read_text(encoding="utf-8") + assert "environment remove" in text, path.name + assert "environment cleanup" in text, path.name + + def test_changelog_records_the_validate_flag_rename(self): + text = (PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + unreleased = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0] + assert "--splunk-config-aws" in unreleased + assert "--splunk-config aws" in unreleased diff --git a/tests/test_spa_apps.py b/tests/test_spa_apps.py index ff081fec..8df11f9d 100644 --- a/tests/test_spa_apps.py +++ b/tests/test_spa_apps.py @@ -741,6 +741,14 @@ def test_search_itsi_fetches_1841_when_missing_from_page(): assert [row["app_id"] for row in data["apps"]].count(1841) == 1 +def test_search_itsi_fetches_library_5391_when_missing_from_page(): + client = FakeClient(apps=[_itsi(), _pack()], by_id={5391: _library()}) + data = search("itsi", limit=10, client=client) + ids = [row["app_id"] for row in data["apps"]] + assert ids[:2] == [1841, 5391] + assert ids.count(5391) == 1 + + def test_search_unix_does_not_pin_itsi(): data = search("unix", limit=10, client=FakeClient(apps=[_ta(), _itsi()])) assert [row["app_id"] for row in data["apps"]] == [833, 1841] @@ -1383,6 +1391,85 @@ def test_snippet_customize_snow_uses_env_lookup_not_password(): assert "snow_password:" in data["snippet"] +def _itsi_hits(): + return [ + { + "app_id": 1841, + "name": "itsi", + "title": "Splunk IT Service Intelligence", + "type": "app", + "kind": "premium_itsi", + "version": "5.0.2", + "summary": "ITSI monitoring", + }, + { + "app_id": 3598, + "name": "xmatters_itsi", + "title": "xMatters for Splunk ITSI", + "type": "app", + "kind": "ta", + "version": "1.7.4", + "summary": "xMatters integration", + }, + { + "app_id": 5391, + "name": "DA-ITSI-ContentLibrary", + "title": "Splunk IT Service Intelligence Content Library", + "type": "app", + "kind": "itsi_content_library", + "version": "2.5.1", + "summary": "Content packs library", + }, + { + "app_id": 7375, + "name": "DA-ITSI-CP-CUST-ATLAS-PFSENSE", + "title": "Atlas ITSI Content Pack for Pfsense", + "type": "addon", + "kind": "itsi_content_pack_single", + "version": "1.0.0", + "summary": "pfSense content pack", + }, + ] + + +def test_search_human_text_groups_itsi_hits_into_sections(): + lines = format_search_text("itsi", _itsi_hits()).splitlines() + core = lines.index("Core ITSI") + library = lines.index("Content packs (from Library 5391)") + single = lines.index("Single content packs") + other = lines.index("Other apps") + assert core < library < single < other + assert lines[core + 1].startswith("1841 | ") + assert library < next(i for i, line in enumerate(lines) if line.startswith("5391 | ")) + assert single < next(i for i, line in enumerate(lines) if line.startswith("7375 | ")) + assert other < next(i for i, line in enumerate(lines) if line.startswith("3598 | ")) + + +def test_search_human_text_library_section_shows_pack_commands(): + text = format_search_text("itsi", _itsi_hits()) + library_part = text.split("Content packs (from Library 5391)", 1)[1].split("Single content packs", 1)[0] + assert "spa apps content-packs list 5391" in library_part + assert "spa apps add 5391 --content-pack NAME" in library_part + + +def test_search_human_text_single_pack_names_core_itsi_dependency(): + text = format_search_text("itsi", _itsi_hits()) + single_part = text.split("Single content packs", 1)[1].split("Other apps", 1)[0] + assert "spa apps add 7375" in single_part + assert "1841" in single_part + + +def test_search_human_text_without_itsi_hits_keeps_flat_list(): + rows = [ + {"app_id": 833, "name": "Splunk_TA_nix", "title": "Unix", "type": "addon", "kind": "ta"}, + {"app_id": 1928, "name": "Splunk_TA_snow", "title": "ServiceNow", "type": "addon", "kind": "ta"}, + ] + lines = format_search_text("unix", rows).splitlines() + assert lines[0].startswith("833 | ") + assert lines[1].startswith("1928 | ") + assert "Core ITSI" not in format_search_text("unix", rows) + + def test_search_marks_playbook_on_matching_hit(): result = search("unix", spa_home=PROJECT_ROOT, client=FakeClient(apps=[_ta()])) assert result["apps"][0]["playbook"] is True diff --git a/tests/test_splunk_config_aws.py b/tests/test_splunk_config_aws.py index db55bcac..77544fcf 100644 --- a/tests/test_splunk_config_aws.py +++ b/tests/test_splunk_config_aws.py @@ -213,6 +213,33 @@ def test_list_regions_uses_aws_default_region(monkeypatch, capsys, tmp_path): assert "Using region" not in captured.out +def test_list_regions_reports_the_aws_config_region_beside_the_environment_region(monkeypatch, capsys, tmp_path): + _aws_config(monkeypatch, tmp_path, "us-east-1") + monkeypatch.setattr(aws, "_session", lambda region=None: _FakeRegionSession()) + + assert aws.main(["regions", "list", "--json"], environment_region="eu-central-1") == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["region"] == "eu-central-1" + assert payload["region_source"] == "terraform.aws.region" + assert payload["aws_config_region"] == "us-east-1" + assert payload["aws_config_profile"] == "default" + + +def test_list_regions_reports_a_missing_profile_as_json_not_a_traceback(monkeypatch, capsys, tmp_path): + _aws_config(monkeypatch, tmp_path, "eu-central-1") + monkeypatch.setenv("AWS_PROFILE", "no-such-profile") + + code = aws.main(["regions", "list", "--json"], environment_region="eu-central-1") + captured = capsys.readouterr() + assert code == 1 + payload = json.loads(captured.out) + assert payload["ok"] is False + assert "no-such-profile" in payload["error"] + assert payload["region"] == "eu-central-1" + assert payload["items"] == [] + assert "Traceback" not in captured.err + + def test_regional_aws_command_uses_the_config_region_and_names_the_source(monkeypatch, capsys, tmp_path): _aws_config(monkeypatch, tmp_path, "eu-central-1") seen = {} From 24514ca68a96b947340dd6273a9fb64f55858410 Mon Sep 17 00:00:00 2001 From: Marco Stadler Date: Wed, 28 Oct 2026 16:30:05 +0100 Subject: [PATCH 3/3] Skip the missing-profile test when boto3 is absent The test exercises a real botocore ProfileNotFound, so it needs boto3. CI images without boto3 failed it before reaching the profile check. Co-authored-by: Cursor --- tests/test_splunk_config_aws.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_splunk_config_aws.py b/tests/test_splunk_config_aws.py index 77544fcf..4c7a37a1 100644 --- a/tests/test_splunk_config_aws.py +++ b/tests/test_splunk_config_aws.py @@ -226,6 +226,8 @@ def test_list_regions_reports_the_aws_config_region_beside_the_environment_regio def test_list_regions_reports_a_missing_profile_as_json_not_a_traceback(monkeypatch, capsys, tmp_path): + # Needs real botocore to raise ProfileNotFound; CI images without boto3 cannot exercise it. + pytest.importorskip("boto3") _aws_config(monkeypatch, tmp_path, "eu-central-1") monkeypatch.setenv("AWS_PROFILE", "no-such-profile")