From 251c49381a7a546b82c88bc4653d98a9ea4cd409 Mon Sep 17 00:00:00 2001 From: kewlx Date: Sat, 2 May 2026 22:50:39 -0500 Subject: [PATCH 1/4] Docs: clarify region selection and task API Clarify region-selection behavior and task interface across docs and examples. Added a Region Selection section to YAML validation reference and README, noting that organizations can use explicit regions, `all`, globs, or mixed selectors while account configs require explicit region names. Expanded docs/README.md with detailed CLI examples (auth, graph, run output) and moved large output examples out of the top-level README. Updated task contract docs to show ActionRecorder usage, stricter typing for metadata, actions parameter type, and that sessions are scoped to account+region. Minor example YAMLs updated with comments demonstrating region selector rules. --- .../references/yaml-and-validation.md | 44 +++- README.md | 225 +++--------------- docs/README.md | 208 +++++++++++++++- examples/12-complete-org-reference.yaml | 6 + examples/13-complete-account-reference.yaml | 1 + 5 files changed, 291 insertions(+), 193 deletions(-) diff --git a/.agents/skills/anvil-task-builder/references/yaml-and-validation.md b/.agents/skills/anvil-task-builder/references/yaml-and-validation.md index 62aaea6..55c074f 100644 --- a/.agents/skills/anvil-task-builder/references/yaml-and-validation.md +++ b/.agents/skills/anvil-task-builder/references/yaml-and-validation.md @@ -24,6 +24,48 @@ organizations: Use `depends_on` when task order matters. Use `optional: true` only when failure should not fail the account or block dependent work. +## Region Selection + +Use explicit region names for `accounts:` configs: + +```yaml +regions: + - us-east-1 + - us-west-2 +``` + +`organizations:` configs can also use region selectors. `all` must be the only +region value: + +```yaml +regions: + - all +``` + +Organization region globs can be used alone, combined with other globs, or mixed +with explicit regions: + +```yaml +regions: + - us-* +``` + +```yaml +regions: + - us-* + - eu-* +``` + +```yaml +regions: + - us-* + - ca-central-1 +``` + +Region selectors are resolved against discovered AWS regions. Anvil executes only +enabled matches, warns for matched disabled regions, rejects glob selectors that +match no known region, and fails when no enabled region remains. + ## Validation After creating or editing a task, run: @@ -44,4 +86,4 @@ For YAML examples, also validate the config schema or run the relevant example t ```powershell $env:PYTHONPATH='src'; python -c "from pathlib import Path; import yaml; from anvil.validators import validate_config_schema; path=Path('examples/example.yaml'); validate_config_schema(config=yaml.safe_load(path.read_text(encoding='utf-8')) or {})" -``` \ No newline at end of file +``` diff --git a/README.md b/README.md index 749ba83..eab343f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Anvil is built for teams that need repeatable AWS workflows, such as inventory, - Configure organizations, account lists, regions, tasks, task dependencies, dry runs, fail-fast behavior, and concurrency in one place. - Multi-account and multi-organization by default - Automatically discover active accounts and enabled regions for each AWS Organization. - - Run only against configured regions that are enabled for that organization. + - Run only against configured organization regions that are enabled, including regions selected by `all` or glob patterns. - Support explicit account groups and include/exclude filters. - Assume roles into member accounts. - Let account owners, admins, governance teams, and security teams run approved tasks at the scope they control. @@ -159,85 +159,17 @@ anvil auth check --help Authenticate credentials from an organization file. ```console anvil auth check --config-file ./yaml/orgs.yaml - -INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO -INFO [auth.py:auth_check:106] Running auth check for org=other-root profile=other-root auth_source=AuthSource.SSO -INFO [auth.py:auth_check:106] Running auth check for org=random-root profile=random-root auth_source=AuthSource.UNKNOWN -WARNING [credentials.py:_protected_refresh:603] Refreshing temporary credentials failed during mandatory refresh period. -botocore.exceptions.UnauthorizedSSOTokenError: The SSO session associated with this profile has expired or is otherwise invalid. To refresh this SSO session run aws sso login with the corresponding profile. -{ - "generated_at": "2026-03-31T15:30:15.075014+00:00", - "auth": [ - { - "org_name": "root", - "status": "error", - "source": "sso", - "started_at": "2026-03-31T15:30:14.836545+00:00", - "ended_at": "2026-03-31T15:30:15.074440+00:00", - "duration_seconds": 0.23789780004881322, - "message": "AWS SSO session is invalid or expired.", - "remediation": "aws sso login --profile root" - }, - { - "org_name": "other-root", - "status": "error", - "source": "sso", - "started_at": "2026-03-31T15:30:14.841167+00:00", - "ended_at": "2026-03-31T15:30:15.072661+00:00", - "duration_seconds": 0.23149509984068573, - "message": "AWS SSO session is invalid or expired.", - "remediation": "aws sso login --profile other-root" - }, - { - "org_name": "random-root", - "status": "error", - "source": "unknown", - "started_at": "2026-03-31T15:30:14.849622+00:00", - "ended_at": "2026-03-31T15:30:14.904089+00:00", - "duration_seconds": 0.054468399845063686, - "message": "AWS profile not found.", - "remediation": "Fix your AWS profile configuration." - } - ] -} - - -INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO -{ - "generated_at": "2026-03-31T15:34:56.998631+00:00", - "auth": [ - { - "org_name": "root", - "status": "success", - "source": "sso", - "started_at": "2026-03-31T15:34:54.844004+00:00", - "ended_at": "2026-03-31T15:34:56.971776+00:00", - "duration_seconds": 2.1277707000263035, - "message": "Authenticated successfully.", - "remediation": null - }, - { - "org_name": "other-root", - "status": "success", - "source": "sso", - "started_at": "2026-03-31T15:34:54.848072+00:00", - "ended_at": "2026-03-31T15:34:56.998306+00:00", - "duration_seconds": 2.1502324000466615, - "message": "Authenticated successfully.", - "remediation": null - } - ] -} ``` -Suppress all output and rely on the exit code only (useful for CI) +Suppress all output and rely on the exit code only (useful for CI). See [Authentication output](docs/README.md#authentication-output) for detailed examples. ```console anvil auth check --config-file orgs.yaml --quiet -INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO ``` + + ### Graph -Display the resolved task dependency graph for an organization configuration. +Display the resolved task dependency graph for an organization configuration. See [Graph output](docs/README.md#graph-output) for detailed examples. ```console anvil graph --help @@ -246,43 +178,13 @@ anvil graph --help Generate a dependency graph from an organization file. ```console anvil graph --config-file .\examples\07-optional-task-semantics.yaml - -Execution Graph (optional-semantics-org) ----------------------------------------- -inventory -└── reporting - └── cleanup ``` -Output graph results as JSON +Output graph results as JSON. ```console anvil graph --config-file .\examples\07-optional-task-semantics.yaml --json - -{ - "organization": "optional-semantics-org", - "tasks": [ - { - "name": "inventory", - "depends_on": [] - }, - { - "name": "reporting", - "depends_on": [ - "inventory" - ] - }, - { - "name": "cleanup", - "depends_on": [ - "reporting" - ] - } - ] -} ``` - - ### Task Management List all available stock and user-defined tasks ```console @@ -318,11 +220,12 @@ anvil tasks validate ``` ### Execution +Execute all configured organizations and accounts from one or more YAML files. See [Run output and result layout](docs/README.md#run-output-and-result-layout) for detailed examples. ```console anvil run --help ``` -Execute all configured organizations and accounts from one or more YAML files, write per-target full results, write a flattened query file, and produce one summary file per YAML in a run-scoped result directory: +Anvil writes per-target full results, write a flattened query file, and produce one summary file per YAML in a run-scoped result directory: ```text results/ @@ -334,67 +237,13 @@ results/ .json ``` -Account-group configs use `account-groups/` for per-target JSON files instead of `organizations/`. -```console -anvil run --config-file ./yaml/noop.yaml -INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO -INFO [organization.py:execute:39] Starting organization processing (org=root, region=us-east-1) -INFO [account.py:execute:48] Processing account root (123456789000) -INFO [account.py:execute:48] Processing account account1 (111111111111) -INFO [account.py:execute:48] Processing account account2 (222222222222) -INFO [noop.py:run:33] No-op task executed for account root (123456789000), dry_run=False -INFO [account.py:execute:48] Processing account Log Archive (333333333333) -INFO [account.py:execute:48] Processing account Audit (444444444444) -INFO [noop.py:run:33] No-op task executed for account account1 (111111111111), dry_run=False -INFO [noop.py:run:33] No-op task executed for account Audit (444444444444), dry_run=False -INFO [noop.py:run:33] No-op task executed for account Log Archive (333333333333), dry_run=False -INFO [noop.py:run:33] No-op task executed for account account2 (222222222222), dry_run=False -...... -INFO [cli.py:_write_run_results:132] Wrote run results to xxxx\xxxx\results\noop\2026-05-01T183012Z: summary=xxxx\xxxx\results\noop\2026-05-01T183012Z\summary.json, target_files=1, jsonl_records=50 - -#Summary below -{ - "state": "completed_success", - "generated_at": "2026-03-17T18:48:47.392583+00:00", - "auth": [ - { - "org_name": "root", - "status": "success", - "source": "sso", - "started_at": "2026-03-17T18:48:36.615369+00:00", - "ended_at": "2026-03-17T18:48:38.338430+00:00", - "duration_seconds": 1.7230594999855384, - "message": "Authenticated successfully.", - "remediation": null - } - ], - "organizations": [ - { - "organization": "root", - "total_accounts": 50, - "failed_accounts": 0, - "interrupted_accounts": 0, - "failed_tasks": 0, - "has_failures": false, - "error": null - } - ], - "total_failed_accounts": 0, - "total_interrupted_accounts": 0, - "total_failed_tasks": 0 -} -``` -Use `--benchmark` only for performance investigations. It adds engine, target, -account, region, and result-write timing details to result JSON, which can -dramatically increase output size on large account, region, or task runs. Leave -it off for normal audit/reporting runs, and enable it when comparing benchmark -runs or looking for bottlenecks. +Use `--benchmark` only for performance investigations. It adds engine, target, account, region, and result-write timing details to result JSON, which can dramatically increase output size on large account, region, or task runs. +Leave it off for normal audit/reporting runs, and enable it when comparing benchmark runs or looking for bottlenecks. ### Result Queries -Runs still write the existing full JSON result files. They also write JSONL -records that flatten account and task results for quick filtering: +Runs still write the existing full JSON result files. They also write JSONL records that flatten account and task results for quick filtering: `./results/{config-stem}/{run-id}/results.jsonl`. Common queries: @@ -430,6 +279,11 @@ To run multiple YAML files in one command, pass them after a single `--config-fi anvil run --config-file ./yaml/orgs.yaml ./yaml/orgs2.yaml ./yaml/orgs3.yaml ``` +### Region Selection + +- `organizations:` configs can use explicit regions, `all`, glob selectors, or mixed glob and explicit selectors. +- `accounts:` configs require explicit region names only. See the YAML examples for complete region selection examples and edge-case behavior. + Within a single YAML, you can bound how many configured targets run in parallel. This is separate from each target's `max_workers` and `max_parallel_regions` settings: ```yaml schema_version: 1 @@ -469,6 +323,16 @@ Anvil discovers tasks from two sources: Directories named `tasks/` are conventional only and are not automatically scanned. +#### Reference tasks in YAML +Once configured, custom tasks behave exactly like stock tasks: + +```yaml +tasks: + - name: inventory + - name: cleanup + depends_on: [inventory] +``` + ### Implement the Task Contract @@ -476,17 +340,19 @@ Each task module must define a callable `run` function. This is the minimum interface required for Anvil to discover and execute a task. ```python +from anvil.actions import ActionRecorder + def run( *, account_id: str, account_alias: str, session, dry_run: bool, - metadata: dict, - actions=None, -): + metadata: dict[str, object], + actions: ActionRecorder, +) -> None: """ - Execute the task for a single AWS account. + Execute the task for one AWS account-region pair. """ ``` @@ -494,7 +360,7 @@ def run( - `account_id` - AWS account ID currently being processed. - `account_alias` - Friendly name of the account. -- `session` - A boto3 Session already scoped to the target account. +- `session` - A boto3 Session already scoped to the target account and region. - `dry_run` - Indicates whether the task should make changes. - `metadata` - Organization metadata defined in the configuration file. - `actions` - Action recorder provided by Anvil for planned or completed work. @@ -505,35 +371,16 @@ The return value is optional. Any returned data may be included in execution res ### Optional Helpers (Advanced Usage) -While only the `run()` function is required, tasks can optionally use Anvil-provided utilities to produce structured results. - -For example, tasks may import helpers such as: - -```python -from anvil.actions import ActionRecorder -``` - -This helper allows tasks to: +Tasks can use Anvil-provided utilities to produce structured results. `ActionRecorder` allows tasks to: - record planned or executed actions - produce structured output for reporting - integrate with Anvil’s execution summaries -You can view returned-result and ActionRecorder examples here: -[Results](./examples/Results/README.md) +You can view returned-result and ActionRecorder examples here, [Results](./examples/Results/README.md) -Using these utilities is **not required**, but recommended for tasks that modify infrastructure or need richer audit output. - -### Reference tasks in YAML -Once configured, custom tasks behave exactly like stock tasks: - -```yaml -tasks: - - name: inventory - - name: cleanup - depends_on: [inventory] -``` +Using these utilities is **not required**, but recommended for tasks that modify infrastructure or need richer audit output. [pytest-badge]:https://github.com/JSChronicles/anvil/actions/workflows/pytest.yaml/badge.svg?branch=main diff --git a/docs/README.md b/docs/README.md index 589d7b7..e156125 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ At a high level: 1. Each organization can declare its own profile, role, regions, worker limits, region concurrency, task graph, include or exclude filters, dry-run behavior, and fail-fast behavior. 1. Each YAML can optionally declare `max_parallel_targets` to bound how many configured targets are allowed to execute at once. 1. Anvil validates the YAML against the packaged JSON Schema and semantic target rules before execution starts. -1. For each organization, Anvil authenticates, creates an organization-scoped base session, discovers eligible accounts, validates configured regions against enabled regions, and builds the effective account execution set. +1. For each organization, Anvil authenticates, creates an organization-scoped base session, discovers eligible accounts, discovers region statuses, validates configured regions against enabled regions, and builds the effective account execution set. 1. Selected accounts execute in parallel within that organization, bounded by the configured worker limit. 1. Within an account, tasks execute in dependency order for each effective configured region, with optional bounded region concurrency. 1. Results are captured at task, account, organization, and engine scope. @@ -66,7 +66,7 @@ flowchart TD P1["Create base session"] P1 --> P2["Read org identity"] P2 --> P3["Discover active accounts"] - P3 --> P4["Discover enabled regions"] + P3 --> P4["Discover region statuses"] P4 --> P5["Validate configured regions"] P5 --> P6["Apply include/exclude filters"] P6 --> P7["Build account list"] @@ -139,7 +139,7 @@ Anvil supports defining multiple organizations in a single run. Each organizatio This allows a single execution to coordinate work across separate AWS environments without forcing them into a shared credential model or shared runtime configuration. -When one YAML contains multiple targets that resolve to the same AWS organization, Anvil reuses organization discovery results during that run. The first target to discover active accounts and enabled regions populates a run-local cache keyed by organization ID. Concurrent preparation for the same organization waits for that in-flight discovery instead of issuing duplicate `list_accounts` and `list_regions` calls. Target execution is still serialized per organization later in the pipeline so two same-organization targets do not execute account work at the same time. +When one YAML contains multiple targets that resolve to the same AWS organization, Anvil reuses organization discovery results during that run. The first target to discover active accounts and region statuses populates a run-local cache keyed by organization ID. Concurrent preparation for the same organization waits for that in-flight discovery instead of issuing duplicate `list_accounts` and `list_regions` calls. Target execution is still serialized per organization later in the pipeline so two same-organization targets do not execute account work at the same time. ### Multi-region execution @@ -315,6 +315,208 @@ Auth check normalizes several common authentication problems into clearer messag Where possible, Anvil also includes remediation guidance such as re-running SSO login for the affected profile. +## Detailed CLI examples + +### Authentication output + +Authenticate credentials from an organization file: + +```console +anvil auth check --config-file ./yaml/orgs.yaml + +INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO +INFO [auth.py:auth_check:106] Running auth check for org=other-root profile=other-root auth_source=AuthSource.SSO +INFO [auth.py:auth_check:106] Running auth check for org=random-root profile=random-root auth_source=AuthSource.UNKNOWN +WARNING [credentials.py:_protected_refresh:603] Refreshing temporary credentials failed during mandatory refresh period. +botocore.exceptions.UnauthorizedSSOTokenError: The SSO session associated with this profile has expired or is otherwise invalid. To refresh this SSO session run aws sso login with the corresponding profile. +{ + "generated_at": "2026-03-31T15:30:15.075014+00:00", + "auth": [ + { + "org_name": "root", + "status": "error", + "source": "sso", + "started_at": "2026-03-31T15:30:14.836545+00:00", + "ended_at": "2026-03-31T15:30:15.074440+00:00", + "duration_seconds": 0.23789780004881322, + "message": "AWS SSO session is invalid or expired.", + "remediation": "aws sso login --profile root" + }, + { + "org_name": "other-root", + "status": "error", + "source": "sso", + "started_at": "2026-03-31T15:30:14.841167+00:00", + "ended_at": "2026-03-31T15:30:15.072661+00:00", + "duration_seconds": 0.23149509984068573, + "message": "AWS SSO session is invalid or expired.", + "remediation": "aws sso login --profile other-root" + }, + { + "org_name": "random-root", + "status": "error", + "source": "unknown", + "started_at": "2026-03-31T15:30:14.849622+00:00", + "ended_at": "2026-03-31T15:30:14.904089+00:00", + "duration_seconds": 0.054468399845063686, + "message": "AWS profile not found.", + "remediation": "Fix your AWS profile configuration." + } + ] +} +``` + +A successful authentication check returns success records for each configured target: + +```console +INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO +{ + "generated_at": "2026-03-31T15:34:56.998631+00:00", + "auth": [ + { + "org_name": "root", + "status": "success", + "source": "sso", + "started_at": "2026-03-31T15:34:54.844004+00:00", + "ended_at": "2026-03-31T15:34:56.971776+00:00", + "duration_seconds": 2.1277707000263035, + "message": "Authenticated successfully.", + "remediation": null + }, + { + "org_name": "other-root", + "status": "success", + "source": "sso", + "started_at": "2026-03-31T15:34:54.848072+00:00", + "ended_at": "2026-03-31T15:34:56.998306+00:00", + "duration_seconds": 2.1502324000466615, + "message": "Authenticated successfully.", + "remediation": null + } + ] +} +``` + +### Graph output + +Generate a dependency graph from an organization file: + +```console +anvil graph --config-file .\examples\07-optional-task-semantics.yaml + +Execution Graph (optional-semantics-org) +---------------------------------------- +inventory +`-- reporting + `-- cleanup +``` + +Output graph results as JSON: + +```console +anvil graph --config-file .\examples\07-optional-task-semantics.yaml --json + +{ + "organization": "optional-semantics-org", + "tasks": [ + { + "name": "inventory", + "depends_on": [] + }, + { + "name": "reporting", + "depends_on": [ + "inventory" + ] + }, + { + "name": "cleanup", + "depends_on": [ + "reporting" + ] + } + ] +} +``` + +### Run output and result layout + +Organization configs write per-target result files under `organizations/`: + +```text +results/ + / + / + summary.json + results.jsonl + organizations/ + .json +``` + +Account-group configs use `account-groups/` for per-target JSON files instead of `organizations/`: + +```text +results/ + / + / + summary.json + results.jsonl + account-groups/ + .json +``` + +Example run output: + +```console +anvil run --config-file ./yaml/noop.yaml +INFO [auth.py:auth_check:106] Running auth check for org=root profile=root auth_source=AuthSource.SSO +INFO [organization.py:execute:39] Starting organization processing (org=root, region=us-east-1) +INFO [account.py:execute:48] Processing account root (123456789000) +INFO [account.py:execute:48] Processing account account1 (111111111111) +INFO [account.py:execute:48] Processing account account2 (222222222222) +INFO [noop.py:run:33] No-op task executed for account root (123456789000), dry_run=False +INFO [account.py:execute:48] Processing account Log Archive (333333333333) +INFO [account.py:execute:48] Processing account Audit (444444444444) +INFO [noop.py:run:33] No-op task executed for account account1 (111111111111), dry_run=False +INFO [noop.py:run:33] No-op task executed for account Audit (444444444444), dry_run=False +INFO [noop.py:run:33] No-op task executed for account Log Archive (333333333333), dry_run=False +INFO [noop.py:run:33] No-op task executed for account account2 (222222222222), dry_run=False +...... +INFO [cli.py:_write_run_results:132] Wrote run results to xxxx\xxxx\results\noop\2026-05-01T183012Z: summary=xxxx\xxxx\results\noop\2026-05-01T183012Z\summary.json, target_files=1, jsonl_records=50 + +# Summary below +{ + "state": "completed_success", + "generated_at": "2026-03-17T18:48:47.392583+00:00", + "auth": [ + { + "org_name": "root", + "status": "success", + "source": "sso", + "started_at": "2026-03-17T18:48:36.615369+00:00", + "ended_at": "2026-03-17T18:48:38.338430+00:00", + "duration_seconds": 1.7230594999855384, + "message": "Authenticated successfully.", + "remediation": null + } + ], + "organizations": [ + { + "organization": "root", + "total_accounts": 50, + "failed_accounts": 0, + "interrupted_accounts": 0, + "failed_tasks": 0, + "has_failures": false, + "error": null + } + ], + "total_failed_accounts": 0, + "total_interrupted_accounts": 0, + "total_failed_tasks": 0 +} +``` + ## Task validation Anvil includes a task validation mode that checks discovered tasks for structural correctness without executing them. This helps catch task-definition issues before a run begins. diff --git a/examples/12-complete-org-reference.yaml b/examples/12-complete-org-reference.yaml index 0ed5d5f..e563a4a 100644 --- a/examples/12-complete-org-reference.yaml +++ b/examples/12-complete-org-reference.yaml @@ -4,6 +4,8 @@ max_parallel_targets: 2 organizations: - name: place profile: place-root + # Organizations support explicit regions, all by itself, glob selectors, + # and mixed glob plus explicit selectors. regions: - us-east-1 - us-west-2 @@ -35,6 +37,10 @@ organizations: - name: audit profile: audit-root + # Example alternatives: + # regions: [all] + # regions: [us-*] + # regions: [us-*, ca-central-1] regions: - us-west-2 role_name: OrganizationAccountAccessRole diff --git a/examples/13-complete-account-reference.yaml b/examples/13-complete-account-reference.yaml index 932af02..e97f683 100644 --- a/examples/13-complete-account-reference.yaml +++ b/examples/13-complete-account-reference.yaml @@ -4,6 +4,7 @@ max_parallel_targets: 2 accounts: - name: app-team profile: team-base + # Account configs require explicit region names. regions: - us-east-1 role_name: TeamExecutionRole From eab61b2265ca209d6aa7fdcb7d93e8af497b3afa Mon Sep 17 00:00:00 2001 From: kewlx Date: Sat, 2 May 2026 22:52:05 -0500 Subject: [PATCH 2/4] Add region selector support and opt-in statuses Introduce a new anvil.regions module to manage region selectors, globs and AWS region opt-in statuses. Add resolution logic (resolve_region_selectors), bootstrap region selection for preflight calls, and helpers to classify selectors. Update TargetDescriptor validation to forbid mixing 'all' with other regions and to reject selectors for ACCOUNTS branch. Replace discovery of a simple enabled-region list with discover_region_statuses returning a dict of region -> opt-in status; OrganizationResolver and runner plumbing now pass and consume this map and resolve selectors against it. Update JSON schemas to document selectors for orgs and restrict account configs to explicit regions. Add and adjust tests to cover selector expansion, error cases, warnings for unavailable regions, and use of bootstrap region during preflight. --- src/anvil/descriptors.py | 16 +++ src/anvil/organization.py | 46 ++++---- src/anvil/regions.py | 121 +++++++++++++++++++++ src/anvil/runner.py | 26 +++-- src/anvil/schemas/accounts.schema.v1.json | 12 +- src/anvil/schemas/common.schema.v1.json | 2 +- src/anvil/schemas/orgs.schema.v1.json | 22 +++- tests/runner/test_organization_resolver.py | 96 ++++++++++++++-- tests/runner/test_runner_flow.py | 82 ++++++++++++-- tests/validators/test_org_validation.py | 38 +++++++ 10 files changed, 402 insertions(+), 59 deletions(-) create mode 100644 src/anvil/regions.py diff --git a/src/anvil/descriptors.py b/src/anvil/descriptors.py index 14b8b72..3391d77 100644 --- a/src/anvil/descriptors.py +++ b/src/anvil/descriptors.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field from enum import StrEnum +from anvil.regions import ALL_REGION_SELECTOR, is_region_selector + class ConfigBranch(StrEnum): ORGANIZATIONS = "organizations" @@ -61,6 +63,11 @@ def __post_init__(self) -> None: if len(set(normalized_regions)) != len(normalized_regions): raise ValueError("regions must not contain duplicates") + if ALL_REGION_SELECTOR in normalized_regions and normalized_regions != [ + ALL_REGION_SELECTOR + ]: + raise ValueError("regions selector 'all' must be the only region value") + object.__setattr__(self, "regions", normalized_regions) normalized_include = self._normalize_account_ids(self.include) @@ -78,6 +85,15 @@ def __post_init__(self) -> None: return if self.config_branch is ConfigBranch.ACCOUNTS: + account_region_selectors = [ + region for region in self.regions if is_region_selector(region) + ] + if account_region_selectors: + raise ValueError( + "accounts config entries require explicit region names; " + f"selectors are not allowed: {', '.join(account_region_selectors)}" + ) + if not self.include: raise ValueError("accounts config entries require include") diff --git a/src/anvil/organization.py b/src/anvil/organization.py index 71fd0fb..5157315 100644 --- a/src/anvil/organization.py +++ b/src/anvil/organization.py @@ -8,6 +8,7 @@ from anvil.account import Account from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext +from anvil.regions import get_bootstrap_region, resolve_region_selectors from anvil.session import BOTO_CONFIG, SessionFactory __LOGGER__ = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def __init__( session_factory: SessionFactory | None = None, base_session: Session | None = None, discovered_accounts: dict[str, dict[str, str]] | None = None, - enabled_regions: list[str] | None = None, + region_statuses: dict[str, str] | None = None, ) -> None: self.descriptor = descriptor self.context = context @@ -35,7 +36,7 @@ def __init__( self._session_factory = session_factory or SessionFactory() self._base_session = base_session self._discovered_accounts = discovered_accounts - self._enabled_regions = enabled_regions + self._region_statuses = region_statuses def resolve_accounts(self) -> list[Account]: __LOGGER__.info( @@ -44,11 +45,12 @@ def resolve_accounts(self) -> list[Account]: ) base_session = self._base_session or self._session_factory.create_base_session( - profile_name=self.descriptor.profile, region_name=self.context.regions[0] + profile_name=self.descriptor.profile, + region_name=get_bootstrap_region(self.context.regions), ) effective_regions = self._get_effective_regions( - base_session, discovered_regions=self._enabled_regions + base_session, region_statuses=self._region_statuses ) if not effective_regions: raise ValueError("No effective configured regions remain after validation.") @@ -134,24 +136,24 @@ def discover_accounts(session: boto3.Session) -> dict[str, dict[str, str]]: return accounts @staticmethod - def discover_enabled_regions(session: boto3.Session) -> list[str]: + def discover_region_statuses(session: boto3.Session) -> dict[str, str]: """ - Discover enabled AWS regions available to this organization context. + Discover AWS region opt-in statuses available to this organization context. """ account_client = session.client("account", config=BOTO_CONFIG) paginator = account_client.get_paginator("list_regions") - enabled_regions: set[str] = set() + region_statuses: dict[str, str] = {} for page in paginator.paginate(): for region in page.get("Regions", []): region_name = region.get("RegionName") region_status = region.get("RegionOptStatus") - if region_name and region_status in {"ENABLED", "ENABLED_BY_DEFAULT"}: - enabled_regions.add(region_name) + if region_name and region_status: + region_statuses[region_name] = region_status - return sorted(enabled_regions) + return dict(sorted(region_statuses.items())) def _filter_accounts( self, all_accounts: dict[str, dict[str, str]] @@ -185,22 +187,16 @@ def _filter_accounts( return {account_id: all_accounts[account_id] for account_id in remaining_ids} def _get_effective_regions( - self, session: boto3.Session, *, discovered_regions: list[str] | None = None + self, session: boto3.Session, *, region_statuses: dict[str, str] | None = None ) -> list[str]: """ - Intersect configured regions with discovered enabled regions and warn on - configured regions that are unavailable. + Resolve configured regions and selectors against discovered region statuses. """ - discovered_regions = set( - discovered_regions or self.discover_enabled_regions(session) - ) - configured_regions = list(self.context.regions) + if region_statuses is None: + region_statuses = self.discover_region_statuses(session) - unavailable_regions = sorted(set(configured_regions) - discovered_regions) - if unavailable_regions: - __LOGGER__.warning( - f"Org '{self.descriptor.name}' configured unavailable regions: " - f"{', '.join(unavailable_regions)}" - ) - - return [region for region in configured_regions if region in discovered_regions] + return resolve_region_selectors( + target_name=self.descriptor.name, + configured_regions=list(self.context.regions), + region_statuses=region_statuses, + ) diff --git a/src/anvil/regions.py b/src/anvil/regions.py new file mode 100644 index 0000000..9fc06c1 --- /dev/null +++ b/src/anvil/regions.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import fnmatch +import logging + +__LOGGER__ = logging.getLogger(__name__) + +ALL_REGION_SELECTOR = "all" +ENABLED_REGION_STATUSES = {"ENABLED", "ENABLED_BY_DEFAULT"} +REGION_GLOB_CHARS = frozenset("*?[") +DEFAULT_BOOTSTRAP_REGION = "us-east-1" + + +def is_region_glob(region: str) -> bool: + """Return whether a configured region uses glob selector syntax.""" + return any(char in region for char in REGION_GLOB_CHARS) + + +def is_region_selector(region: str) -> bool: + """Return whether a configured region must be expanded before execution. + + Concrete region names, such as us-east-1, can be passed directly to boto3. + Selectors, such as all or us-*, describe a set of regions and must be + resolved against discovered AWS regions first. + """ + return region == ALL_REGION_SELECTOR or is_region_glob(region) + + +def get_bootstrap_region(configured_regions: list[str]) -> str: + """Return a concrete region for preflight AWS discovery calls. + + Organization preflight needs a real boto3 region before region selectors can + be expanded. Prefer the first explicit configured region when present; + otherwise fall back to us-east-1 for discovery-only calls. + """ + for region in configured_regions: + if not is_region_selector(region): + return region + + return DEFAULT_BOOTSTRAP_REGION + + +def resolve_region_selectors( + *, + target_name: str, + configured_regions: list[str], + region_statuses: dict[str, str], +) -> list[str]: + """Resolve configured region selectors to concrete enabled regions. + + Args: + target_name: Target name for log and error messages. + configured_regions: Regions or selectors from YAML. + region_statuses: Region name to AWS opt-in status from Account list_regions. + + Returns: + Concrete enabled regions to execute. + + Raises: + ValueError: If a selector matches no known region or no enabled regions remain. + """ + known_regions = sorted(region_statuses) + selected_regions: set[str] = set() + selected_order: list[str] = [] + unmatched_selectors: list[str] = [] + + def add_region(region: str) -> None: + if region in selected_regions: + return + + selected_regions.add(region) + selected_order.append(region) + + if configured_regions == [ALL_REGION_SELECTOR]: + for region in known_regions: + add_region(region) + else: + for configured_region in configured_regions: + if is_region_glob(configured_region): + matches = [ + region + for region in known_regions + if fnmatch.fnmatchcase(region, configured_region) + ] + if not matches: + unmatched_selectors.append(configured_region) + continue + + for region in matches: + add_region(region) + continue + + add_region(configured_region) + + if unmatched_selectors: + raise ValueError( + f"Target '{target_name}' region selector(s) matched no known regions: " + f"{', '.join(unmatched_selectors)}" + ) + + unavailable_regions = sorted( + region + for region in selected_regions + if region_statuses.get(region) not in ENABLED_REGION_STATUSES + ) + if unavailable_regions: + __LOGGER__.warning( + f"Target '{target_name}' configured unavailable regions: " + f"{', '.join(unavailable_regions)}" + ) + + effective_regions = [ + region + for region in selected_order + if region_statuses.get(region) in ENABLED_REGION_STATUSES + ] + + if not effective_regions: + raise ValueError("No effective configured regions remain after validation.") + + return effective_regions diff --git a/src/anvil/runner.py b/src/anvil/runner.py index 13dca8c..32692a8 100644 --- a/src/anvil/runner.py +++ b/src/anvil/runner.py @@ -18,6 +18,7 @@ from anvil.execution_context import ExecutionContext from anvil.executor import execute_accounts from anvil.organization import OrganizationResolver +from anvil.regions import get_bootstrap_region from anvil.results import ( AuthResult, EngineResult, @@ -110,7 +111,7 @@ class PreparedTarget: organization_id: str | None = None management_account_id: str | None = None discovered_accounts: dict[str, dict[str, str]] | None = None - enabled_regions: list[str] | None = None + region_statuses: dict[str, str] | None = None benchmark: dict[str, object] | None = None @property @@ -166,7 +167,7 @@ def get_or_check( class OrganizationRunCacheEntry: management_account_id: str discovered_accounts: dict[str, dict[str, str]] - enabled_regions: list[str] + region_statuses: dict[str, str] @dataclass(frozen=True, slots=True) @@ -335,12 +336,13 @@ def _preflight_organization( session_factory: SessionFactory, organization_cache: OrganizationRunCache, benchmark: dict[str, object] | None = None, -) -> tuple[Session, str, str, dict[str, dict[str, str]], list[str]]: +) -> tuple[Session, str, str, dict[str, dict[str, str]], dict[str, str]]: sink = BenchmarkRecorder(data=benchmark) with sink.phase("create_base_session_seconds"): base_session: Session = session_factory.create_base_session( - profile_name=target.profile, region_name=context.regions[0] + profile_name=target.profile, + region_name=get_bootstrap_region(context.regions), ) with sink.phase("describe_organization_seconds"): @@ -352,15 +354,15 @@ def discover_organization() -> OrganizationRunCacheEntry: with sink.phase("discover_accounts_seconds"): discovered_accounts = OrganizationResolver.discover_accounts(base_session) - with sink.phase("discover_enabled_regions_seconds"): - enabled_regions = OrganizationResolver.discover_enabled_regions( + with sink.phase("discover_region_statuses_seconds"): + region_statuses = OrganizationResolver.discover_region_statuses( base_session ) return OrganizationRunCacheEntry( management_account_id=management_account_id, discovered_accounts=discovered_accounts, - enabled_regions=enabled_regions, + region_statuses=region_statuses, ) lookup = organization_cache.get_or_discover( @@ -374,7 +376,7 @@ def discover_organization() -> OrganizationRunCacheEntry: organization_id, lookup.entry.management_account_id, lookup.entry.discovered_accounts, - lookup.entry.enabled_regions, + lookup.entry.region_statuses, ) @@ -428,14 +430,14 @@ def prepare_target( organization_id: str | None = None management_account_id: str | None = None discovered_accounts: dict[str, dict[str, str]] | None = None - enabled_regions: list[str] | None = None + region_statuses: dict[str, str] | None = None if effective_target.is_organization_config: ( base_session, organization_id, management_account_id, discovered_accounts, - enabled_regions, + region_statuses, ) = _preflight_organization( target=effective_target, context=context, @@ -454,7 +456,7 @@ def prepare_target( organization_id=organization_id, management_account_id=management_account_id, discovered_accounts=discovered_accounts, - enabled_regions=enabled_regions, + region_statuses=region_statuses, benchmark=recorder.data, ) @@ -474,7 +476,7 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu session_factory=prepared_target.session_factory, base_session=prepared_target.base_session, discovered_accounts=prepared_target.discovered_accounts, - enabled_regions=prepared_target.enabled_regions, + region_statuses=prepared_target.region_statuses, ) else: resolver = AccountResolver( diff --git a/src/anvil/schemas/accounts.schema.v1.json b/src/anvil/schemas/accounts.schema.v1.json index b8e7923..3d571cc 100644 --- a/src/anvil/schemas/accounts.schema.v1.json +++ b/src/anvil/schemas/accounts.schema.v1.json @@ -37,7 +37,17 @@ "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/profile" }, "regions": { - "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/regions" + "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/regions", + "description": "Explicit AWS region names for account task execution. Account configs do not support 'all' or glob selectors.", + "examples": [ + [ + "us-east-1" + ], + [ + "us-east-1", + "us-west-2" + ] + ] }, "role_name": { "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/role_name" diff --git a/src/anvil/schemas/common.schema.v1.json b/src/anvil/schemas/common.schema.v1.json index 7815a1e..98e78d0 100644 --- a/src/anvil/schemas/common.schema.v1.json +++ b/src/anvil/schemas/common.schema.v1.json @@ -51,7 +51,7 @@ "default": [ "us-east-1" ], - "description": "AWS regions for task execution.", + "description": "AWS regions or region selectors for task execution.", "items": { "type": "string", "minLength": 1 diff --git a/src/anvil/schemas/orgs.schema.v1.json b/src/anvil/schemas/orgs.schema.v1.json index eb960db..5c81210 100644 --- a/src/anvil/schemas/orgs.schema.v1.json +++ b/src/anvil/schemas/orgs.schema.v1.json @@ -36,7 +36,27 @@ "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/profile" }, "regions": { - "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/regions" + "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/regions", + "description": "Organization regions for task execution. Use explicit regions, 'all' by itself, glob selectors, or mixed glob and explicit selectors.", + "examples": [ + [ + "us-east-1" + ], + [ + "all" + ], + [ + "us-*" + ], + [ + "us-*", + "eu-*" + ], + [ + "us-*", + "ca-central-1" + ] + ] }, "role_name": { "$ref": "common.schema.v1.json#/$defs/baseTargetProperties/role_name" diff --git a/tests/runner/test_organization_resolver.py b/tests/runner/test_organization_resolver.py index eb99f98..61bc2ae 100644 --- a/tests/runner/test_organization_resolver.py +++ b/tests/runner/test_organization_resolver.py @@ -99,7 +99,7 @@ def test_discover_accounts_keeps_active_accounts_and_defaults_alias(): } -def test_discover_enabled_regions_filters_and_sorts_regions(): +def test_discover_region_statuses_keeps_enabled_and_disabled_statuses(): session = FakeSession( clients={ "account": FakeClient( @@ -130,10 +130,11 @@ def test_discover_enabled_regions_filters_and_sorts_regions(): } ) - assert OrganizationResolver.discover_enabled_regions(session) == [ - "us-east-1", - "us-west-2", - ] + assert OrganizationResolver.discover_region_statuses(session) == { + "us-east-1": "ENABLED_BY_DEFAULT", + "us-west-2": "ENABLED", + "ap-south-1": "DISABLED", + } def test_filter_accounts_intersects_include_and_exclude_filters(): @@ -170,7 +171,7 @@ def test_resolve_accounts_uses_preflight_data_and_builds_account_modes(): session_factory=FailingSessionFactory(), base_session=base_session, discovered_accounts=discovered_accounts, - enabled_regions=["us-east-1"], + region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, ) accounts = resolver.resolve_accounts() @@ -187,6 +188,71 @@ def test_resolve_accounts_uses_preflight_data_and_builds_account_modes(): assert accounts[1]._regions == ["us-east-1"] +def test_resolve_accounts_expands_all_region_selector(): + resolver = OrganizationResolver( + descriptor=_target(regions=["all"]), + context=_context(regions=["all"]), + management_account_id="111111111111", + base_session=object(), + discovered_accounts={ + "111111111111": { + "account_number": "111111111111", + "account_alias": "management", + } + }, + region_statuses={ + "us-east-1": "ENABLED_BY_DEFAULT", + "us-west-1": "DISABLED", + "us-west-2": "ENABLED", + }, + ) + + accounts = resolver.resolve_accounts() + + assert accounts[0]._regions == ["us-east-1", "us-west-2"] + + +def test_resolve_accounts_expands_glob_and_explicit_region_selectors(caplog): + resolver = OrganizationResolver( + descriptor=_target(regions=["us-*", "ca-central-1"]), + context=_context(regions=["us-*", "ca-central-1"]), + management_account_id="111111111111", + base_session=object(), + discovered_accounts={ + "111111111111": { + "account_number": "111111111111", + "account_alias": "management", + } + }, + region_statuses={ + "ca-central-1": "ENABLED", + "eu-west-1": "ENABLED", + "us-east-1": "ENABLED_BY_DEFAULT", + "us-west-1": "DISABLED", + "us-west-2": "ENABLED", + }, + ) + + accounts = resolver.resolve_accounts() + + assert accounts[0]._regions == ["us-east-1", "us-west-2", "ca-central-1"] + assert "configured unavailable regions: us-west-1" in caplog.text + + +def test_resolve_accounts_rejects_glob_matching_no_known_regions(): + resolver = OrganizationResolver( + descriptor=_target(regions=["moon-*"]), + context=_context(regions=["moon-*"]), + management_account_id="111111111111", + base_session=object(), + discovered_accounts={}, + region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, + ) + + with pytest.raises(ValueError, match="matched no known regions"): + resolver.resolve_accounts() + + def test_resolve_accounts_raises_when_no_effective_regions_remain(): resolver = OrganizationResolver( descriptor=_target(regions=["us-east-1"]), @@ -194,8 +260,24 @@ def test_resolve_accounts_raises_when_no_effective_regions_remain(): management_account_id="111111111111", base_session=object(), discovered_accounts={}, - enabled_regions=["us-west-2"], + region_statuses={"us-west-2": "ENABLED"}, ) with pytest.raises(ValueError, match="No effective configured regions"): resolver.resolve_accounts() + + +def test_resolve_accounts_raises_when_selector_matches_only_disabled_regions(caplog): + resolver = OrganizationResolver( + descriptor=_target(regions=["ap-*"]), + context=_context(regions=["ap-*"]), + management_account_id="111111111111", + base_session=object(), + discovered_accounts={}, + region_statuses={"ap-south-1": "DISABLED", "us-east-1": "ENABLED"}, + ) + + with pytest.raises(ValueError, match="No effective configured regions"): + resolver.resolve_accounts() + + assert "configured unavailable regions: ap-south-1" in caplog.text diff --git a/tests/runner/test_runner_flow.py b/tests/runner/test_runner_flow.py index 99adf97..8b25792 100644 --- a/tests/runner/test_runner_flow.py +++ b/tests/runner/test_runner_flow.py @@ -93,7 +93,7 @@ def fake_auth_check(**kwargs): "account_alias": "management", } }, - ["us-east-1"], + {"us-east-1": "ENABLED_BY_DEFAULT"}, ), ) monkeypatch.setattr( @@ -142,7 +142,7 @@ def test_prepare_target_reuses_same_org_discovery_cache(monkeypatch): discovered_accounts = { "111111111111": {"account_number": "111111111111", "account_alias": "acct-a"} } - enabled_regions = ["us-east-1", "us-west-2"] + region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT", "us-west-2": "ENABLED"} call_counts = {"accounts": 0, "regions": 0} monkeypatch.setattr( @@ -178,14 +178,14 @@ def fake_discover_accounts(session): def fake_discover_regions(session): call_counts["regions"] += 1 - return enabled_regions + return region_statuses monkeypatch.setattr( "anvil.runner.OrganizationResolver.discover_accounts", staticmethod(fake_discover_accounts), ) monkeypatch.setattr( - "anvil.runner.OrganizationResolver.discover_enabled_regions", + "anvil.runner.OrganizationResolver.discover_region_statuses", staticmethod(fake_discover_regions), ) @@ -230,8 +230,66 @@ def fake_discover_regions(session): assert prepared_b.base_session is not None assert prepared_a.discovered_accounts == discovered_accounts assert prepared_b.discovered_accounts == discovered_accounts - assert prepared_a.enabled_regions == enabled_regions - assert prepared_b.enabled_regions == enabled_regions + assert prepared_a.region_statuses == region_statuses + assert prepared_b.region_statuses == region_statuses + + +def test_prepare_target_uses_bootstrap_region_for_region_selector(monkeypatch): + created_session_regions: list[str] = [] + + monkeypatch.setattr( + "anvil.runner._run_cached_auth_check_for_target", + lambda target, auth_cache: AuthResult( + target_name=target.name, + status=ExecutionStatus.SUCCESS, + source="test", + started_at="start", + ended_at="end", + duration_seconds=0.0, + message="ok", + ), + ) + monkeypatch.setattr( + "anvil.runner.resolve_tasks", + lambda task_specs: ResolvedExecution(ordered=[], adjacency={}), + ) + + class FakeSessionFactory: + def create_base_session(self, **kwargs): + created_session_regions.append(kwargs["region_name"]) + return type("_BaseSession", (), {"profile_name": kwargs["profile_name"]})() + + monkeypatch.setattr("anvil.runner.SessionFactory", FakeSessionFactory) + monkeypatch.setattr( + "anvil.runner.OrganizationResolver.describe_organization", + staticmethod(lambda session: ("o-shared", "999999999999")), + ) + monkeypatch.setattr( + "anvil.runner.OrganizationResolver.discover_accounts", + staticmethod(lambda session: {}), + ) + monkeypatch.setattr( + "anvil.runner.OrganizationResolver.discover_region_statuses", + staticmethod(lambda session: {"us-east-1": "ENABLED_BY_DEFAULT"}), + ) + + prepare_target( + index=0, + target=TargetDescriptor( + config_branch=ConfigBranch.ORGANIZATIONS, + name="org-a", + profile="shared", + regions=["all"], + tasks=[], + ), + cli_dry_run=None, + cli_include=None, + cli_exclude=None, + organization_cache=OrganizationRunCache(), + auth_cache=AuthCheckCache(), + ) + + assert created_session_regions == ["us-east-1"] def test_organization_run_cache_single_flights_concurrent_discovery(): @@ -243,7 +301,7 @@ def test_organization_run_cache_single_flights_concurrent_discovery(): "account_alias": "acct-a", } }, - enabled_regions=["us-east-1"], + region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, ) cache = OrganizationRunCache() discovery_started = threading.Event() @@ -328,7 +386,7 @@ def waiter_lookup(): entry = OrganizationRunCacheEntry( management_account_id="999999999999", discovered_accounts={}, - enabled_regions=["us-east-1"], + region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, ) retry_lookup = cache.get_or_discover( organization_id="o-shared", discover=lambda: entry @@ -359,7 +417,7 @@ def test_run_prepared_target_uses_cached_org_preflight(monkeypatch): discovered_accounts = { "111111111111": {"account_number": "111111111111", "account_alias": "acct-a"} } - enabled_regions = ["us-east-1"] + region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT"} class FakeSessionFactory: def create_base_session(self, **kwargs): @@ -382,10 +440,10 @@ def create_base_session(self, **kwargs): ), ) monkeypatch.setattr( - "anvil.organization.OrganizationResolver.discover_enabled_regions", + "anvil.organization.OrganizationResolver.discover_region_statuses", staticmethod( lambda session: (_ for _ in ()).throw( - AssertionError("execution should not rediscover enabled regions") + AssertionError("execution should not rediscover region statuses") ) ), ) @@ -420,7 +478,7 @@ def create_base_session(self, **kwargs): organization_id="o-shared", management_account_id="999999999999", discovered_accounts=discovered_accounts, - enabled_regions=enabled_regions, + region_statuses=region_statuses, ) outcome = run_prepared_target(prepared_target=prepared_target) diff --git a/tests/validators/test_org_validation.py b/tests/validators/test_org_validation.py index 4b42e45..b6054d1 100644 --- a/tests/validators/test_org_validation.py +++ b/tests/validators/test_org_validation.py @@ -54,6 +54,44 @@ def test_max_parallel_regions_accepts_maximum_value(): assert descriptor.max_parallel_regions == 4 +def test_organization_regions_accepts_all_selector(): + descriptor = TargetDescriptor( + config_branch=ConfigBranch.ORGANIZATIONS, name="org", regions=["all"] + ) + + assert descriptor.regions == ["all"] + + +def test_organization_regions_accepts_globs_and_explicit_regions(): + descriptor = TargetDescriptor( + config_branch=ConfigBranch.ORGANIZATIONS, + name="org", + regions=["us-*", "ca-central-1"], + ) + + assert descriptor.regions == ["us-*", "ca-central-1"] + + +def test_regions_rejects_all_mixed_with_other_regions(): + with pytest.raises(ValueError, match="'all' must be the only region value"): + TargetDescriptor( + config_branch=ConfigBranch.ORGANIZATIONS, + name="org", + regions=["all", "us-east-1"], + ) + + +@pytest.mark.parametrize("regions", [["all"], ["us-*"]]) +def test_accounts_regions_reject_selectors(regions): + with pytest.raises(ValueError, match="selectors are not allowed"): + TargetDescriptor( + config_branch=ConfigBranch.ACCOUNTS, + name="group", + include=["111111111111"], + regions=regions, + ) + + @pytest.mark.parametrize("max_parallel_regions", [0, 5]) def test_max_parallel_regions_rejects_out_of_range_values(max_parallel_regions): with pytest.raises(ValueError, match="max_parallel_regions"): From dcb32f3a4509d36bf75d33b7773c5c5cad0b1ed6 Mon Sep 17 00:00:00 2001 From: kewlx Date: Sat, 2 May 2026 22:57:00 -0500 Subject: [PATCH 3/4] feat: add organization region selectors --- src/anvil/regions.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/anvil/regions.py b/src/anvil/regions.py index 9fc06c1..96c4ccf 100644 --- a/src/anvil/regions.py +++ b/src/anvil/regions.py @@ -41,10 +41,7 @@ def get_bootstrap_region(configured_regions: list[str]) -> str: def resolve_region_selectors( - *, - target_name: str, - configured_regions: list[str], - region_statuses: dict[str, str], + *, target_name: str, configured_regions: list[str], region_statuses: dict[str, str] ) -> list[str]: """Resolve configured region selectors to concrete enabled regions. From 6e16d6b7fd43b962032c142e9d82bd8b46b38d3c Mon Sep 17 00:00:00 2001 From: kewlx Date: Sat, 2 May 2026 23:01:22 -0500 Subject: [PATCH 4/4] fix labeler --- .github/workflows/labeler.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/labeler.yaml b/.github/workflows/labeler.yaml index 1610560..c24792c 100644 --- a/.github/workflows/labeler.yaml +++ b/.github/workflows/labeler.yaml @@ -17,6 +17,9 @@ jobs: run: shell: bash steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run Labeler uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: