From 6e1ed2d9dc03abd13064e3ee5f7c6c6a2e36be0b Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:42:45 -0700 Subject: [PATCH 1/8] Refactor provider contract and target handling Shift target mode/option/filter validation out of shared descriptors into provider implementations, and extend the provider contract with `resolve_target_filters` and `prepare_target` for provider-owned preflight state and scheduling keys. This also unifies runner auth/preparation/execution flow around provider APIs, migrates AWS account/auth/session modules under `providers/aws`, and updates loaders to use shared component descriptors. Results were enriched with provider/entity metadata and task action output, and schemas/tests were updated to reflect provider-owned target semantics. --- src/anvil/cli.py | 52 +- src/anvil/descriptors.py | 250 +-- src/anvil/execution_context.py | 1 - src/anvil/processor_loader.py | 33 +- src/anvil/provider_loader.py | 30 +- src/anvil/{ => providers/aws}/account.py | 12 +- .../{ => providers/aws}/account_resolver.py | 11 +- src/anvil/{ => providers/aws}/auth.py | 0 src/anvil/providers/aws/config.py | 13 + src/anvil/{ => providers/aws}/organization.py | 11 +- src/anvil/providers/aws/provider.py | 174 +- src/anvil/providers/aws/regions.py | 2 +- src/anvil/{ => providers/aws}/session.py | 0 src/anvil/providers/azure/provider.py | 106 +- src/anvil/providers/azure/session.py | 5 - src/anvil/providers/base.py | 110 +- src/anvil/providers/gcp/provider.py | 77 +- src/anvil/providers/gcp/session.py | 5 - src/anvil/providers/github/provider.py | 93 +- src/anvil/providers/github/session.py | 5 - src/anvil/result_query.py | 6 + src/anvil/results.py | 12 +- src/anvil/runner.py | 419 +--- src/anvil/schemas/common.schema.v2.json | 214 +- src/anvil/schemas/targets.schema.v2.json | 413 +--- src/anvil/task_context.py | 1 - src/anvil/task_invocation.py | 20 +- src/anvil/task_loader.py | 248 +-- src/anvil/validators.py | 4 + tests/auth/test_auth sources.py | 2 +- tests/auth/test_auth_check.py | 2 +- tests/cli/test_cli_parallel_targets.py | 9 +- tests/cli/test_cli_smoke.py | 22 +- tests/cli/test_list_command.py | 17 +- tests/cli/test_validate_command.py | 38 +- tests/providers/aws/test_execution_targets.py | 24 +- tests/providers/aws/test_regions.py | 13 +- tests/providers/aws/test_runtime.py | 15 +- tests/providers/azure/test_azure_provider.py | 69 +- tests/providers/gcp/test_gcp_provider.py | 12 +- .../providers/github/test_github_provider.py | 26 +- tests/providers/test_provider_contract.py | 110 +- tests/providers/test_provider_loader.py | 30 +- .../test_provider_owned_target_validation.py | 80 + tests/results/test_result_query.py | 4 + tests/results/test_results.py | 3 + tests/runner/test_account_resolver.py | 26 +- tests/runner/test_organization_resolver.py | 15 +- tests/runner/test_provider_execution.py | 13 +- tests/runner/test_runner_auth_pool.py | 787 +++---- tests/runner/test_runner_flow.py | 1861 +++-------------- tests/runner/test_session_factory.py | 4 +- tests/tasks/test_provider_task_loader.py | 61 +- tests/tasks/test_task_loader.py | 54 +- tests/tasks/test_task_loader_cache.py | 56 +- .../test_task_loader_cache_integration.py | 96 +- tests/test_loader_plugin_entry_points.py | 28 +- tests/validators/test_config_loading.py | 27 +- tests/validators/test_org_validation.py | 175 +- 59 files changed, 1948 insertions(+), 4058 deletions(-) rename src/anvil/{ => providers/aws}/account.py (94%) rename src/anvil/{ => providers/aws}/account_resolver.py (80%) rename src/anvil/{ => providers/aws}/auth.py (100%) create mode 100644 src/anvil/providers/aws/config.py rename src/anvil/{ => providers/aws}/organization.py (94%) rename src/anvil/{ => providers/aws}/session.py (100%) delete mode 100644 src/anvil/providers/azure/session.py delete mode 100644 src/anvil/providers/gcp/session.py delete mode 100644 src/anvil/providers/github/session.py create mode 100644 tests/providers/test_provider_owned_target_validation.py diff --git a/src/anvil/cli.py b/src/anvil/cli.py index 747241f..1742e0d 100644 --- a/src/anvil/cli.py +++ b/src/anvil/cli.py @@ -36,7 +36,12 @@ run_processors, ) from anvil.processor_validation import processor_validation_errors -from anvil.provider_loader import ProviderDescriptor, discover_providers, list_providers +from anvil.provider_loader import ( + ProviderDescriptor, + discover_providers, + list_providers, + load_provider, +) from anvil.providers.base import validate_provider_contract from anvil.result_query import ( ResultFilters, @@ -96,7 +101,7 @@ class ListableDescriptor(Protocol): """Descriptor fields needed for grouped CLI listing.""" name: str - source: str + source: object class DetailDescriptor(ListableDescriptor, Protocol): @@ -137,19 +142,15 @@ def _load_targets_from_config_file(path: Path) -> LoadedConfig: def _validate_cli_overrides( *, loaded_config: LoadedConfig, args: argparse.Namespace ) -> None: - """ - Validate branch-specific CLI override semantics. - """ - if loaded_config.branch is ConfigBranch.TARGETS and args.exclude is not None: - explicit_targets = [ - target for target in loaded_config.targets if target.is_explicit_mode - ] - if explicit_targets: - target_names = ", ".join(target.name for target in explicit_targets) - raise ValueError( - "CLI --exclude is not supported for explicit provider modes; " - f"target(s): {target_names}" - ) + """Validate CLI filter overrides using each target's provider contract.""" + + include = getattr(args, "include", None) + exclude = getattr(args, "exclude", None) + for target in loaded_config.targets: + provider = load_provider(target.provider) + provider.resolve_target_filters( + target=target, include_override=include, exclude_override=exclude + ) def _add_common_config_args(parser: argparse.ArgumentParser) -> None: @@ -417,12 +418,13 @@ def _print_grouped_listing( current_source: str | None = None for descriptor in descriptors: - if descriptor.source != current_source: + source_label = str(descriptor.source) + if source_label != current_source: if current_source is not None: print() - print(f"{descriptor.source}:") - current_source = descriptor.source + print(f"{source_label}:") + current_source = source_label print(f" - {descriptor.name}") @@ -460,7 +462,7 @@ def _select_detail_descriptor( ) if len(matches) > 1: - source_display = ", ".join(descriptor.source for descriptor in matches) + source_display = ", ".join(str(descriptor.source) for descriptor in matches) raise ValueError( f"{label.capitalize()} '{name}' is ambiguous; found in multiple " f"sources: {source_display}" @@ -535,12 +537,6 @@ def _select_task_descriptors( ] -def _select_tasks(task_names: list[str]) -> list[TaskDescriptor]: - return _select_task_descriptors( - descriptors=discover_tasks().tasks, task_names=task_names - ) - - def _validate_selected_tasks(task_names: list[str] | None) -> None: discovery = discover_tasks() errors: list[str] = [] @@ -594,12 +590,6 @@ def _select_processor_descriptors( ] -def _select_processors(processor_names: list[str]) -> list[ProcessorDescriptor]: - return _select_processor_descriptors( - descriptors=discover_processors().processors, processor_names=processor_names - ) - - def _validate_selected_processors(processor_names: list[str] | None) -> None: discovery = discover_processors() errors: list[str] = [] diff --git a/src/anvil/descriptors.py b/src/anvil/descriptors.py index 37505dc..2ce3f02 100644 --- a/src/anvil/descriptors.py +++ b/src/anvil/descriptors.py @@ -3,45 +3,6 @@ from dataclasses import dataclass, field from enum import StrEnum -from anvil.regions import ALL_REGION_SELECTOR, is_region_selector - - -PROVIDER_AWS = "aws" -PROVIDER_AZURE = "azure" -PROVIDER_GCP = "gcp" -PROVIDER_GITHUB = "github" - -MODE_AWS_ORGANIZATION = "organization" -MODE_AWS_ACCOUNTS = "accounts" -MODE_AZURE_TENANT = "tenant" -MODE_AZURE_SUBSCRIPTIONS = "subscriptions" -MODE_GCP_ORGANIZATION = "organization" -MODE_GCP_PROJECTS = "projects" -MODE_GITHUB_ORGANIZATIONS = "organizations" -MODE_GITHUB_REPOSITORIES = "repositories" - -SUPPORTED_PROVIDERS = {PROVIDER_AWS, PROVIDER_AZURE, PROVIDER_GCP, PROVIDER_GITHUB} -SUPPORTED_PROVIDER_MODES = { - PROVIDER_AWS: {MODE_AWS_ORGANIZATION, MODE_AWS_ACCOUNTS}, - PROVIDER_AZURE: {MODE_AZURE_TENANT, MODE_AZURE_SUBSCRIPTIONS}, - PROVIDER_GCP: {MODE_GCP_ORGANIZATION, MODE_GCP_PROJECTS}, - PROVIDER_GITHUB: {MODE_GITHUB_ORGANIZATIONS, MODE_GITHUB_REPOSITORIES}, -} -SUPPORTED_PROVIDER_OPTIONS = { - PROVIDER_AWS: {"profile", "role_name"}, - PROVIDER_AZURE: {"tenant_id", "client_id", "client_secret"}, - PROVIDER_GCP: {"credentials_path", "organization_id", "quota_project_id"}, - PROVIDER_GITHUB: { - "api_url", - "api_version", - "token_env", - "app_id", - "private_key_env", - "private_key_path", - "profile", - }, -} - class ConfigBranch(StrEnum): TARGETS = "targets" @@ -57,9 +18,9 @@ class TargetDescriptor: config_branch: ConfigBranch name: str - profile: str | None = None + provider: str + mode: str regions: list[str] | None = None - role_name: str | None = None tasks: list[dict[str, object]] = field(default_factory=lambda: [{"name": "noop"}]) post_run: list[dict[str, object]] = field(default_factory=list) @@ -72,71 +33,18 @@ class TargetDescriptor: exclude: list[str] | None = None metadata: dict[str, object] = field(default_factory=dict) - provider: str = PROVIDER_AWS - mode: str | None = None provider_options: dict[str, object] = field(default_factory=dict) - @property - def is_organization_config(self) -> bool: - return self.provider == PROVIDER_AWS and self.mode == MODE_AWS_ORGANIZATION - - @property - def is_accounts_config(self) -> bool: - return ( - self.provider == PROVIDER_AWS - and self.mode == MODE_AWS_ACCOUNTS - or self.provider == PROVIDER_AZURE - and self.mode == MODE_AZURE_SUBSCRIPTIONS - or self.provider == PROVIDER_GCP - and self.mode == MODE_GCP_PROJECTS - or self.provider == PROVIDER_GITHUB - and self.mode == MODE_GITHUB_REPOSITORIES - ) - - @property - def is_discovery_mode(self) -> bool: - return ( - self.provider == PROVIDER_AWS - and self.mode == MODE_AWS_ORGANIZATION - or self.provider == PROVIDER_AZURE - and self.mode == MODE_AZURE_TENANT - or self.provider == PROVIDER_GCP - and self.mode == MODE_GCP_ORGANIZATION - or self.provider == PROVIDER_GITHUB - and self.mode == MODE_GITHUB_ORGANIZATIONS - ) - - @property - def is_explicit_mode(self) -> bool: - return not self.is_discovery_mode - - @property - def allows_region_selectors(self) -> bool: - """Return whether this target can expand region/location selectors.""" - - return ( - self.provider == PROVIDER_AWS - and self.mode == MODE_AWS_ORGANIZATION - or self.provider == PROVIDER_AZURE - and self.mode in {MODE_AZURE_TENANT, MODE_AZURE_SUBSCRIPTIONS} - or self.provider == PROVIDER_GCP - and self.mode == MODE_GCP_PROJECTS - ) - def __post_init__(self) -> None: if not isinstance(self.provider, str): raise ValueError("provider must be a string") normalized_provider = self.provider.strip().lower() - if normalized_provider not in SUPPORTED_PROVIDERS: - supported = ", ".join(sorted(SUPPORTED_PROVIDERS)) - raise ValueError( - f"Unsupported provider '{self.provider}'. Supported providers: {supported}" - ) + if not normalized_provider: + raise ValueError("provider must be a non-empty string") object.__setattr__(self, "provider", normalized_provider) self._validate_provider_options() - self._normalize_provider_option_aliases() self._normalize_mode() if self.max_workers < 1: @@ -156,10 +64,6 @@ def __post_init__(self) -> None: raise ValueError("regions must not contain empty values") 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_target_ids(self.include) @@ -171,44 +75,6 @@ def __post_init__(self) -> None: if self.config_branch is ConfigBranch.TARGETS: if self.include is not None and self.exclude is not None: raise ValueError("include and exclude cannot both be set") - - allows_provider_discovery = ( - self.provider == PROVIDER_GCP and self.mode == MODE_GCP_PROJECTS - ) - if self.is_explicit_mode and not allows_provider_discovery: - if not self.include: - raise ValueError( - f"provider '{self.provider}' mode '{self.mode}' requires include" - ) - if self.exclude is not None: - raise ValueError( - f"provider '{self.provider}' mode '{self.mode}' does not allow exclude" - ) - - if ( - self.provider == PROVIDER_AWS - and self.mode == MODE_AWS_ACCOUNTS - and self.role_name is None - and len(self.include or []) != 1 - ): - raise ValueError( - "AWS accounts targets without role_name must include exactly " - "one account ID" - ) - - if not self.allows_region_selectors: - target_region_selectors = [ - region - for region in self.regions or [] - if is_region_selector(region) - ] - if target_region_selectors: - raise ValueError( - f"provider '{self.provider}' mode '{self.mode}' requires " - "explicit region names; selectors are not allowed: " - f"{', '.join(target_region_selectors)}" - ) - return raise ValueError(f"Unsupported config branch: {self.config_branch}") @@ -217,109 +83,19 @@ def _validate_provider_options(self) -> None: if not isinstance(self.provider_options, dict): raise ValueError("provider.options must be a mapping") - if self.provider != PROVIDER_AWS: - if self.profile is not None: - raise ValueError( - f"profile is only supported for provider '{PROVIDER_AWS}'" - ) - if self.role_name is not None: - raise ValueError( - f"role_name is only supported for provider '{PROVIDER_AWS}'" - ) - - allowed_options = SUPPORTED_PROVIDER_OPTIONS[self.provider] - unknown_options = sorted(set(self.provider_options) - allowed_options) - if unknown_options: - unknown_display = ", ".join(unknown_options) - allowed_display = ", ".join(sorted(allowed_options)) or "(none)" - raise ValueError( - f"Unsupported provider.options for provider '{self.provider}': " - f"{unknown_display}. Supported options: {allowed_display}" - ) - - for option_name, option_value in self.provider_options.items(): - if option_value is None: - continue - if not isinstance(option_value, str) or not option_value.strip(): - raise ValueError( - f"provider.options.{option_name} must be a non-empty string" - ) - - if self.provider == PROVIDER_AZURE: - tenant_id = self.provider_options.get("tenant_id") - client_id = self.provider_options.get("client_id") - client_secret = self.provider_options.get("client_secret") - if tenant_id is not None and client_secret is None: - raise ValueError( - "Azure provider.options.tenant_id is only supported with " - "client_secret" - ) - if client_secret is not None and (tenant_id is None or client_id is None): - raise ValueError( - "Azure provider.options.client_secret requires tenant_id and " - "client_id" - ) - - if self.provider == PROVIDER_GITHUB: - profile = self.provider_options.get("profile") - if profile is not None and len(self.provider_options) > 1: - raise ValueError( - "GitHub provider.options.profile cannot be combined with " - "inline GitHub auth options" - ) - - def _normalize_provider_option_aliases(self) -> None: - if self.provider != PROVIDER_AWS: - return - - profile = self.provider_options.get("profile") - if profile is not None: - if not isinstance(profile, str) or not profile.strip(): - raise ValueError("provider.options.profile must be a non-empty string") - if self.profile is not None and self.profile != profile: - raise ValueError( - "profile and provider.options.profile must not conflict" - ) - object.__setattr__(self, "profile", profile.strip()) - - role_name = self.provider_options.get("role_name") - if role_name is not None: - if not isinstance(role_name, str) or not role_name.strip(): - raise ValueError( - "provider.options.role_name must be a non-empty string" - ) - if self.role_name is not None and self.role_name != role_name: - raise ValueError( - "role_name and provider.options.role_name must not conflict" - ) - object.__setattr__(self, "role_name", role_name.strip()) + if any( + not isinstance(option_name, str) or not option_name.strip() + for option_name in self.provider_options + ): + raise ValueError("provider.options keys must be non-empty strings") def _normalize_mode(self) -> None: - mode = self.mode.strip().lower() if isinstance(self.mode, str) else None - if self.mode is not None and not mode: + if not isinstance(self.mode, str): + raise ValueError("mode must be a string") + mode = self.mode.strip().lower() + if not mode: raise ValueError("mode must be a non-empty string") - if mode is None: - if self.provider == PROVIDER_AWS: - mode = ( - MODE_AWS_ACCOUNTS - if self.include is not None - else MODE_AWS_ORGANIZATION - ) - elif self.provider == PROVIDER_AZURE: - mode = MODE_AZURE_SUBSCRIPTIONS - elif self.provider == PROVIDER_GCP: - mode = MODE_GCP_PROJECTS - elif self.provider == PROVIDER_GITHUB: - mode = MODE_GITHUB_REPOSITORIES - - if mode not in SUPPORTED_PROVIDER_MODES[self.provider]: - supported = ", ".join(sorted(SUPPORTED_PROVIDER_MODES[self.provider])) - raise ValueError( - f"Unsupported mode '{mode}' for provider '{self.provider}'. " - f"Supported modes: {supported}" - ) - object.__setattr__(self, "mode", mode) @staticmethod diff --git a/src/anvil/execution_context.py b/src/anvil/execution_context.py index 40c9eac..0f62168 100644 --- a/src/anvil/execution_context.py +++ b/src/anvil/execution_context.py @@ -13,7 +13,6 @@ class ExecutionContext: """ regions: list[str] - role_name: str | None dry_run: bool tasks: list[ResolvedTask] metadata: dict[str, object] diff --git a/src/anvil/processor_loader.py b/src/anvil/processor_loader.py index ecc6343..7b7127a 100644 --- a/src/anvil/processor_loader.py +++ b/src/anvil/processor_loader.py @@ -11,6 +11,7 @@ from anvil._components import ( ComponentCatalog, + ComponentDescriptor, ComponentKind, ComponentOrigin, ComponentResolver, @@ -45,13 +46,7 @@ class ProcessorSpec: run_on_failure: bool = False -@dataclass(frozen=True, slots=True) -class ProcessorDescriptor: - """Discovered processor and lazy loader for its run callable.""" - - name: str - load: Callable[[], Callable] - source: str +ProcessorDescriptor = ComponentDescriptor[Callable] @dataclass(frozen=True, slots=True) @@ -72,12 +67,10 @@ class ProcessorRunContext: summary: dict[str, object] target_result_paths: dict[str, Path] target_name: str | None = None - target_result: TargetResult | dict[str, object] | None = None + target_result: dict[str, object] | None = None target_result_path: Path | None = None target_metadata: dict[str, object] = field(default_factory=dict) - target_results: Sequence[TargetResult | dict[str, object]] = field( - default_factory=list - ) + target_results: Sequence[dict[str, object]] = field(default_factory=list) # ============================================================================ @@ -237,10 +230,10 @@ def run_configured_post_processors( summary=summary, target_result_paths=target_result_paths, target_name=target_result.target_name, - target_result=target_result, + target_result=target_result.to_dict(), target_result_path=target_result_paths.get(target_result.target_name), target_metadata=dict(target.metadata), - target_results=[target_result], + target_results=[target_result.to_dict()], ) resolved_specs: list[ProcessorSpec] = [] @@ -291,12 +284,6 @@ def _load_processor_from_package( return run -def _public_processor_descriptor(descriptor) -> ProcessorDescriptor: - return ProcessorDescriptor( - name=descriptor.name, load=descriptor.load, source=str(descriptor.source) - ) - - @lru_cache(maxsize=16) def _processor_catalog_for_entry_points( plugin_entry_points: tuple[EntryPoint, ...], @@ -355,11 +342,7 @@ def discover_processors() -> ProcessorDiscoveryResult: """Discover processors and report plugin packages that cannot be inspected.""" catalog = _processor_catalog() return ProcessorDiscoveryResult( - processors=[ - _public_processor_descriptor(descriptor) - for descriptor in catalog.descriptors - ], - issues=list(catalog.issues), + processors=list(catalog.descriptors), issues=list(catalog.issues) ) @@ -367,7 +350,7 @@ def list_processors() -> list[ProcessorDescriptor]: """Return processors sorted by source and name.""" return sorted( discover_processors().processors, - key=lambda processor: (processor.source, processor.name), + key=lambda processor: (str(processor.source), processor.name), ) diff --git a/src/anvil/provider_loader.py b/src/anvil/provider_loader.py index c8ad183..971a55a 100644 --- a/src/anvil/provider_loader.py +++ b/src/anvil/provider_loader.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -from collections.abc import Callable from dataclasses import dataclass from functools import lru_cache from importlib.metadata import EntryPoint, entry_points @@ -11,6 +10,7 @@ from anvil._components import ( ComponentCatalog, + ComponentDescriptor, ComponentKind, ComponentOrigin, ComponentResolver, @@ -26,15 +26,7 @@ _RESERVED_PROVIDER_CHILDREN = frozenset({"base", "tasks"}) -@dataclass(frozen=True, slots=True) -class ProviderDescriptor: - """Provider metadata and lazy loader used by CLI discovery.""" - - name: str - display_name: str - load: Callable[[], Provider] - source: str - description: str | None = None +ProviderDescriptor = ComponentDescriptor[Provider] @dataclass(frozen=True, slots=True) @@ -146,28 +138,12 @@ def _provider_catalog() -> ComponentCatalog[Provider]: ) -def _display_name(name: str) -> str: - known_initialisms = {"aws": "AWS", "gcp": "GCP", "github": "GitHub"} - return known_initialisms.get(name, name.replace("_", " ").title()) - - -def _public_descriptor(descriptor) -> ProviderDescriptor: - return ProviderDescriptor( - name=descriptor.name, - display_name=_display_name(descriptor.name), - load=descriptor.load, - source=str(descriptor.source), - ) - - def discover_providers() -> ProviderDiscoveryResult: """Discover provider folders without constructing providers.""" catalog = _provider_catalog() - unique_descriptors = [candidates[0] for candidates in catalog.inventory.values()] return ProviderDiscoveryResult( - providers=[_public_descriptor(item) for item in unique_descriptors], - issues=list(catalog.issues), + providers=list(catalog.descriptors), issues=list(catalog.issues) ) diff --git a/src/anvil/account.py b/src/anvil/providers/aws/account.py similarity index 94% rename from src/anvil/account.py rename to src/anvil/providers/aws/account.py index 41b5afb..d4bf85b 100644 --- a/src/anvil/account.py +++ b/src/anvil/providers/aws/account.py @@ -10,7 +10,7 @@ from boto3.session import Session from anvil.execution_context import ExecutionContext -from anvil.session import AssumedRoleCredentials, SessionFactory +from anvil.providers.aws.session import AssumedRoleCredentials, SessionFactory __LOGGER__ = logging.getLogger(__name__) @@ -44,6 +44,7 @@ def __init__( account_alias: str, is_management: bool, access_strategy: AccountAccessStrategy, + role_name: str | None, base_session: boto3.Session, context: ExecutionContext, regions: list[str], @@ -53,6 +54,7 @@ def __init__( self.account_alias = account_alias self.is_management = is_management self.access_strategy = access_strategy + self.role_name = role_name self._base_session: Session = base_session self._context = context self._regions = regions @@ -66,13 +68,11 @@ def _get_assumed_role_credentials(self) -> AssumedRoleCredentials: profile_name=self._base_session.profile_name, region_name=source_region ) - if self._context.role_name is None: - raise ValueError("Expected role_name for assume-role execution") + if self.role_name is None: + raise ValueError("Assume-role account execution requires role_name") return self._session_factory.assume_role_credentials( - session=worker_session, - account_id=self.account_id, - role_name=self._context.role_name, + session=worker_session, account_id=self.account_id, role_name=self.role_name ) @staticmethod diff --git a/src/anvil/account_resolver.py b/src/anvil/providers/aws/account_resolver.py similarity index 80% rename from src/anvil/account_resolver.py rename to src/anvil/providers/aws/account_resolver.py index 3f3f1af..3026e97 100644 --- a/src/anvil/account_resolver.py +++ b/src/anvil/providers/aws/account_resolver.py @@ -4,10 +4,11 @@ from boto3.session import Session -from anvil.account import Account, AccountAccessStrategy +from anvil.providers.aws.account import Account, AccountAccessStrategy from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext -from anvil.session import SessionFactory +from anvil.providers.aws.config import aws_option +from anvil.providers.aws.session import SessionFactory __LOGGER__ = logging.getLogger(__name__) @@ -36,7 +37,8 @@ def resolve_accounts(self) -> list[Account]: ) base_session: Session = self._session_factory.create_base_session( - profile_name=self.descriptor.profile, region_name=self.context.regions[0] + profile_name=aws_option(self.descriptor, "profile"), + region_name=self.context.regions[0], ) accounts: list[Account] = [] @@ -44,7 +46,7 @@ def resolve_accounts(self) -> list[Account]: for account_id in self.descriptor.include or []: access_strategy = ( AccountAccessStrategy.ASSUME_ROLE - if self.descriptor.role_name is not None + if aws_option(self.descriptor, "role_name") is not None else AccountAccessStrategy.DIRECT_PROFILE ) accounts.append( @@ -53,6 +55,7 @@ def resolve_accounts(self) -> list[Account]: account_alias=account_id, is_management=False, access_strategy=access_strategy, + role_name=aws_option(self.descriptor, "role_name"), base_session=base_session, context=self.context, regions=list(self.context.regions), diff --git a/src/anvil/auth.py b/src/anvil/providers/aws/auth.py similarity index 100% rename from src/anvil/auth.py rename to src/anvil/providers/aws/auth.py diff --git a/src/anvil/providers/aws/config.py b/src/anvil/providers/aws/config.py new file mode 100644 index 0000000..bf11809 --- /dev/null +++ b/src/anvil/providers/aws/config.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from anvil.descriptors import TargetDescriptor + + +DEFAULT_ORGANIZATION_ROLE_NAME = "OrganizationAccountAccessRole" + + +def aws_option(target: TargetDescriptor, name: str) -> str | None: + """Return one validated AWS provider string option.""" + + value = target.provider_options.get(name) + return value if isinstance(value, str) else None diff --git a/src/anvil/organization.py b/src/anvil/providers/aws/organization.py similarity index 94% rename from src/anvil/organization.py rename to src/anvil/providers/aws/organization.py index 3bfda41..a840774 100644 --- a/src/anvil/organization.py +++ b/src/anvil/providers/aws/organization.py @@ -5,11 +5,12 @@ import boto3 from boto3.session import Session -from anvil.account import Account, AccountAccessStrategy +from anvil.providers.aws.account import Account, AccountAccessStrategy from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.aws.regions import AwsRegionService -from anvil.session import BOTO_CONFIG, SessionFactory +from anvil.providers.aws.config import DEFAULT_ORGANIZATION_ROLE_NAME, aws_option +from anvil.providers.aws.session import BOTO_CONFIG, SessionFactory __LOGGER__ = logging.getLogger(__name__) @@ -48,7 +49,7 @@ def resolve_accounts(self) -> list[Account]: ) base_session = self._base_session or self._session_factory.create_base_session( - profile_name=self.descriptor.profile, + profile_name=aws_option(self.descriptor, "profile"), region_name=self._region_service.bootstrap_region( configured_regions=self.context.regions ), @@ -129,6 +130,10 @@ def _build_accounts( account_alias=info["account_alias"], is_management=is_management, access_strategy=access_strategy, + role_name=( + aws_option(self.descriptor, "role_name") + or DEFAULT_ORGANIZATION_ROLE_NAME + ), base_session=base_session, context=self.context, regions=effective_regions, diff --git a/src/anvil/providers/aws/provider.py b/src/anvil/providers/aws/provider.py index 98bbde3..9f4900f 100644 --- a/src/anvil/providers/aws/provider.py +++ b/src/anvil/providers/aws/provider.py @@ -1,30 +1,42 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, replace -from typing import Protocol from boto3.session import Session -from anvil.account import Account, AccountAccessStrategy, _AssumedCredentialState -from anvil.account_resolver import AccountResolver -from anvil.auth import auth_check, infer_auth_source from anvil.benchmark import BenchmarkRecorder from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext -from anvil.organization import OrganizationResolver +from anvil.providers.aws.account import ( + Account, + AccountAccessStrategy, + _AssumedCredentialState, +) +from anvil.providers.aws.account_resolver import AccountResolver +from anvil.providers.aws.auth import auth_check, infer_auth_source +from anvil.providers.aws.config import aws_option +from anvil.providers.aws.organization import OrganizationResolver from anvil.providers.base import ( ExecutionTarget, ProviderAuthResult, ProviderExecutionPlan, + ProviderPreparation, + ProviderPreparationCache, ProviderExecutionRuntime, ProviderMetadata, ProviderRegion, + narrow_include, + validate_region_selectors, + validate_string_options, ) from anvil.providers.aws.regions import AwsRegionService -from anvil.session import CachedClientSession, SessionFactory +from anvil.providers.aws.session import CachedClientSession, SessionFactory DEFAULT_REGIONS = ("us-east-1",) +MODE_ORGANIZATION = "organization" +MODE_ACCOUNTS = "accounts" +SUPPORTED_MODES = frozenset({MODE_ORGANIZATION, MODE_ACCOUNTS}) +SUPPORTED_OPTIONS = frozenset({"profile", "role_name"}) @dataclass(frozen=True, slots=True) @@ -35,6 +47,7 @@ class AwsExecutionTargetData: account_alias: str is_management: bool access_strategy: AccountAccessStrategy + role_name: str | None base_session: Session regions: list[str] session_factory: SessionFactory @@ -62,31 +75,6 @@ class AwsPreflightData: region_statuses: dict[str, str] -@dataclass(frozen=True, slots=True) -class AwsPreflightResult: - """AWS preflight result plus scheduler admission metadata.""" - - data: AwsPreflightData - exclusive_execution_key: str - - -@dataclass(frozen=True, slots=True) -class _AwsOrganizationCacheLookup: - entry: object - hit: bool - waited: bool - - -class _AwsOrganizationCache(Protocol): - def get_or_discover( - self, - *, - organization_id: str, - discover: Callable[[], AwsOrganizationPreflightCacheEntry], - ) -> _AwsOrganizationCacheLookup: - """Return cached or newly discovered organization data.""" - - class AwsExecutionRuntime: """AWS account runtime adapter around the v0.29.2 account lifecycle.""" @@ -177,6 +165,55 @@ def validate_target(self, target: TargetDescriptor) -> None: raise ValueError(f"Unsupported AWS target branch: {target.config_branch}") if target.provider != self.metadata.name: raise ValueError("AWS provider supports provider 'aws' targets only") + if target.mode not in SUPPORTED_MODES: + raise ValueError(f"Unsupported AWS target mode: {target.mode}") + validate_string_options(target=target, allowed_options=SUPPORTED_OPTIONS) + validate_region_selectors( + target=target, selectors_allowed=target.mode == MODE_ORGANIZATION + ) + if target.include is not None and target.exclude is not None: + raise ValueError("AWS include and exclude filters are mutually exclusive") + for account_id in [*(target.include or []), *(target.exclude or [])]: + if len(account_id) != 12 or not account_id.isdigit(): + raise ValueError(f"Invalid AWS account ID: {account_id}") + if target.mode == MODE_ACCOUNTS: + if not target.include: + raise ValueError("AWS mode 'accounts' requires include") + if target.exclude is not None: + raise ValueError("AWS mode 'accounts' does not allow exclude") + if aws_option(target, "role_name") is None and len(target.include) != 1: + raise ValueError( + "AWS accounts targets without role_name must include exactly " + "one account ID" + ) + + def resolve_target_filters( + self, + *, + target: TargetDescriptor, + include_override: list[str] | None, + exclude_override: list[str] | None, + ) -> tuple[list[str] | None, list[str] | None]: + """Apply discovery overrides or narrow explicit AWS account targets.""" + + if target.mode == MODE_ORGANIZATION: + include = ( + include_override if include_override is not None else target.include + ) + exclude = ( + exclude_override if exclude_override is not None else target.exclude + ) + else: + if exclude_override is not None: + raise ValueError("AWS mode 'accounts' does not allow --exclude") + include = narrow_include( + configured=target.include, override=include_override + ) + exclude = None + + effective_target = replace(target, include=include, exclude=exclude) + self.validate_target(effective_target) + return include, exclude def bootstrap_region(self, *, configured_regions: list[str]) -> str: """Return the concrete AWS region used for discovery calls.""" @@ -208,15 +245,18 @@ def resolve_regions( def auth_cache_key(self, target: TargetDescriptor) -> object | None: """Return the same auth cache identity used by the current runner.""" - auth_source = infer_auth_source(target.profile) - return (self.metadata.name, target.profile, auth_source.value) + profile = aws_option(target, "profile") + auth_source = infer_auth_source(profile) + return (self.metadata.name, profile, auth_source.value) def auth_check(self, target: TargetDescriptor) -> ProviderAuthResult: """Run the existing AWS auth check and adapt its result.""" - auth_source = infer_auth_source(target.profile) + self.validate_target(target) + profile = aws_option(target, "profile") + auth_source = infer_auth_source(profile) result = auth_check( - target_name=target.name, profile=target.profile, auth_source=auth_source + target_name=target.name, profile=profile, auth_source=auth_source ) return ProviderAuthResult( status=result.status, @@ -231,7 +271,7 @@ def discover_regions(self, target: TargetDescriptor) -> list[ProviderRegion]: self.validate_target(target) session_factory = SessionFactory() base_session = session_factory.create_base_session( - profile_name=target.profile, + profile_name=aws_option(target, "profile"), region_name=self.bootstrap_region( configured_regions=target.regions or list(self.metadata.default_regions) ), @@ -248,17 +288,19 @@ def resolve_execution_targets( regions: list[str], include: list[str] | None, exclude: list[str] | None, - preflight_data: AwsPreflightData | None = None, + preparation: object | None = None, organization_resolver_cls: type[OrganizationResolver] = OrganizationResolver, account_resolver_cls: type[AccountResolver] = AccountResolver, ) -> ProviderExecutionPlan: """Resolve existing AWS account objects into provider-neutral targets.""" self.validate_target(target) + if preparation is not None and not isinstance(preparation, AwsPreflightData): + raise TypeError("AWS preparation must be AwsPreflightData") + preflight_data = preparation effective_target = replace(target, include=include, exclude=exclude) context = ExecutionContext( regions=regions, - role_name=effective_target.role_name, dry_run=effective_target.dry_run, tasks=[], metadata=effective_target.metadata, @@ -269,7 +311,7 @@ def resolve_execution_targets( preflight_data.session_factory if preflight_data else SessionFactory() ) - if effective_target.is_organization_config: + if effective_target.mode == MODE_ORGANIZATION: resolver = organization_resolver_cls( descriptor=effective_target, context=context, @@ -288,17 +330,12 @@ def resolve_execution_targets( preflight_data.region_statuses if preflight_data else None ), ) - exclusive_execution_key = ( - preflight_data.organization_id if preflight_data else None - ) else: resolver = account_resolver_cls( descriptor=effective_target, context=context, session_factory=resolved_session_factory, ) - exclusive_execution_key = None - accounts = resolver.resolve_accounts() execution_targets = [ _execution_target_from_account( @@ -307,31 +344,30 @@ def resolve_execution_targets( for account in accounts ] - return ProviderExecutionPlan( - execution_targets=execution_targets, - exclusive_execution_key=exclusive_execution_key, - ) + return ProviderExecutionPlan(execution_targets=execution_targets) - def preflight_execution( + def prepare_target( self, *, target: TargetDescriptor, context: ExecutionContext, - session_factory: SessionFactory, - organization_cache: _AwsOrganizationCache, - benchmark: dict[str, object] | None = None, + include: list[str] | None, + exclude: list[str] | None, + cache: ProviderPreparationCache, + benchmark: dict[str, object] | None, organization_resolver_cls: type[OrganizationResolver] = OrganizationResolver, - ) -> AwsPreflightResult: + ) -> ProviderPreparation: """Discover AWS organization execution data before target execution.""" self.validate_target(target) - if not target.is_organization_config: - raise ValueError("AWS preflight requires organization mode") + if target.mode != MODE_ORGANIZATION: + return ProviderPreparation() sink = BenchmarkRecorder(data=benchmark) + session_factory = SessionFactory() with sink.phase("create_base_session_seconds"): base_session = session_factory.create_base_session( - profile_name=target.profile, + profile_name=aws_option(target, "profile"), region_name=self.bootstrap_region(configured_regions=context.regions), ) @@ -360,26 +396,27 @@ def discover_organization() -> AwsOrganizationPreflightCacheEntry: region_statuses=region_statuses, ) - lookup = organization_cache.get_or_discover( - organization_id=organization_id, discover=discover_organization + cached_entry, cache_hit, cache_waited = cache.get_or_create( + key=(self.metadata.name, "organization", organization_id), + create=discover_organization, ) - if not isinstance(lookup.entry, AwsOrganizationPreflightCacheEntry): + if not isinstance(cached_entry, AwsOrganizationPreflightCacheEntry): raise RuntimeError("AWS organization cache returned unexpected value") - sink.set("organization_cache_hit", lookup.hit) - sink.set("organization_cache_waited", lookup.waited) + sink.set("organization_cache_hit", cache_hit) + sink.set("organization_cache_waited", cache_waited) preflight_data = AwsPreflightData( session_factory=session_factory, base_session=base_session, organization_id=organization_id, - management_account_id=lookup.entry.management_account_id, + management_account_id=cached_entry.management_account_id, base_session_account_id=base_session_account_id, - discovered_accounts=lookup.entry.discovered_accounts, - region_statuses=lookup.entry.region_statuses, + discovered_accounts=cached_entry.discovered_accounts, + region_statuses=cached_entry.region_statuses, ) - return AwsPreflightResult( - data=preflight_data, exclusive_execution_key=organization_id + return ProviderPreparation( + data=preflight_data, exclusive_execution_keys=(organization_id,) ) def prepare_execution_runtime( @@ -420,6 +457,7 @@ def _account_from_execution_target( account_alias=data.account_alias, is_management=data.is_management, access_strategy=data.access_strategy, + role_name=data.role_name, base_session=data.base_session, context=context, regions=list(data.regions), @@ -435,6 +473,7 @@ def _execution_target_from_account( account_alias=account.account_alias, is_management=account.is_management, access_strategy=account.access_strategy, + role_name=account.role_name, base_session=account._base_session, regions=list(account._regions), session_factory=account._session_factory, @@ -444,6 +483,7 @@ def _execution_target_from_account( name=account.account_alias, type="account", provider=provider_name, + regions=list(account._regions), metadata={ "account_id": account.account_id, "account_alias": account.account_alias, diff --git a/src/anvil/providers/aws/regions.py b/src/anvil/providers/aws/regions.py index 13f2c6f..6fb2462 100644 --- a/src/anvil/providers/aws/regions.py +++ b/src/anvil/providers/aws/regions.py @@ -8,7 +8,7 @@ get_bootstrap_region, resolve_region_selectors, ) -from anvil.session import BOTO_CONFIG +from anvil.providers.aws.session import BOTO_CONFIG class AwsRegionService: diff --git a/src/anvil/session.py b/src/anvil/providers/aws/session.py similarity index 100% rename from src/anvil/session.py rename to src/anvil/providers/aws/session.py diff --git a/src/anvil/providers/azure/provider.py b/src/anvil/providers/azure/provider.py index d5baa90..6b38426 100644 --- a/src/anvil/providers/azure/provider.py +++ b/src/anvil/providers/azure/provider.py @@ -3,10 +3,10 @@ import logging import threading from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from anvil.benchmark import BenchmarkRecorder -from anvil.descriptors import ConfigBranch, MODE_AZURE_TENANT, TargetDescriptor +from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -14,8 +14,13 @@ ProviderExecutionPlan, ProviderExecutionRuntime, ProviderMetadata, + ProviderPreparation, + ProviderPreparationCache, ProviderRegion, configured_or_default_regions, + narrow_include, + validate_region_selectors, + validate_string_options, ) from anvil.regions import is_region_selector, resolve_location_selectors from anvil.results import ExecutionStatus @@ -23,6 +28,10 @@ __LOGGER__ = logging.getLogger(__name__) DEFAULT_REGIONS = ("eastus",) +MODE_TENANT = "tenant" +MODE_SUBSCRIPTIONS = "subscriptions" +SUPPORTED_MODES = frozenset({MODE_TENANT, MODE_SUBSCRIPTIONS}) +SUPPORTED_OPTIONS = frozenset({"tenant_id", "client_id", "client_secret"}) AZURE_AVAILABLE_LOCATION_STATUS = "available" AZURE_AVAILABLE_LOCATION_STATUSES = {AZURE_AVAILABLE_LOCATION_STATUS} AZURE_EXTRA_REMEDIATION = ( @@ -121,14 +130,6 @@ class AzurePreflightData: location_statuses_by_subscription: dict[str, dict[str, str]] -@dataclass(frozen=True, slots=True) -class AzurePreflightResult: - """Azure preflight result plus scheduler admission metadata.""" - - data: AzurePreflightData | None - exclusive_execution_keys: tuple[object, ...] - - @dataclass(frozen=True, slots=True) class AzureSession: """Lazy Azure runtime session for one subscription and location.""" @@ -362,6 +363,17 @@ def validate_target(self, target: TargetDescriptor) -> None: ) if target.provider != self.metadata.name: raise ValueError("Azure provider supports provider 'azure' targets only") + if target.mode not in SUPPORTED_MODES: + raise ValueError(f"Unsupported Azure target mode: {target.mode}") + validate_string_options(target=target, allowed_options=SUPPORTED_OPTIONS) + validate_region_selectors(target=target, selectors_allowed=True) + if target.include is not None and target.exclude is not None: + raise ValueError("Azure include and exclude filters are mutually exclusive") + if target.mode == MODE_SUBSCRIPTIONS: + if not target.include: + raise ValueError("Azure mode 'subscriptions' requires include") + if target.exclude is not None: + raise ValueError("Azure mode 'subscriptions' does not allow exclude") if ( target.provider_options.get("tenant_id") is not None and target.provider_options.get("client_secret") is None @@ -377,10 +389,42 @@ def validate_target(self, target: TargetDescriptor) -> None: "Azure provider.options.client_secret requires tenant_id and client_id" ) + def resolve_target_filters( + self, + *, + target: TargetDescriptor, + include_override: list[str] | None, + exclude_override: list[str] | None, + ) -> tuple[list[str] | None, list[str] | None]: + """Apply tenant discovery overrides or narrow explicit subscriptions.""" + + if target.mode == MODE_TENANT: + include = ( + include_override if include_override is not None else target.include + ) + exclude = ( + exclude_override if exclude_override is not None else target.exclude + ) + else: + if exclude_override is not None: + raise ValueError("Azure mode 'subscriptions' does not allow --exclude") + include = narrow_include( + configured=target.include, override=include_override + ) + exclude = None + + self.validate_target(replace(target, include=include, exclude=exclude)) + return include, exclude + def auth_cache_key(self, target: TargetDescriptor) -> object | None: """Return a provider auth cache identity without loading Azure SDKs.""" - return (self.metadata.name, target.profile) + return ( + self.metadata.name, + target.provider_options.get("tenant_id"), + target.provider_options.get("client_id"), + target.provider_options.get("client_secret"), + ) def auth_check(self, target: TargetDescriptor) -> ProviderAuthResult: """Validate Azure auth dependencies and ARM token acquisition.""" @@ -436,15 +480,18 @@ def resolve_execution_targets( regions: list[str], include: list[str] | None, exclude: list[str] | None, - preflight_data: AzurePreflightData | None = None, + preparation: object | None = None, ) -> ProviderExecutionPlan: """Resolve Azure subscription IDs deterministically.""" self.validate_target(target) + if preparation is not None and not isinstance(preparation, AzurePreflightData): + raise TypeError("Azure preparation must be AzurePreflightData") + preflight_data = preparation if preflight_data is not None: subscriptions = list(preflight_data.subscriptions) - elif target.mode == MODE_AZURE_TENANT or target.include is None: + elif target.mode == MODE_TENANT or target.include is None: subscriptions = self._resolve_discovered_subscriptions( target=target, include=include, exclude=exclude ) @@ -468,20 +515,22 @@ def resolve_execution_targets( ] return ProviderExecutionPlan(execution_targets=execution_targets) - def preflight_execution( + def prepare_target( self, *, target: TargetDescriptor, - regions: list[str], + context: ExecutionContext, include: list[str] | None, exclude: list[str] | None, - benchmark: dict[str, object] | None = None, - ) -> AzurePreflightResult: + cache: ProviderPreparationCache, + benchmark: dict[str, object] | None, + ) -> ProviderPreparation: """Discover Azure execution data before target execution.""" self.validate_target(target) + regions = context.regions sink = BenchmarkRecorder(data=benchmark) - if target.mode == MODE_AZURE_TENANT or target.include is None: + if target.mode == MODE_TENANT or target.include is None: with sink.phase("azure_discover_subscriptions_seconds"): subscriptions = self._resolve_discovered_subscriptions( target=target, include=include, exclude=exclude @@ -514,31 +563,13 @@ def preflight_execution( location_statuses_by_subscription=location_statuses_by_subscription, ) - return AzurePreflightResult( + return ProviderPreparation( data=preflight_data, exclusive_execution_keys=self._subscription_execution_exclusion_keys( subscriptions=subscriptions ), ) - def execution_exclusion_keys( - self, - *, - target: TargetDescriptor, - include: list[str] | None, - exclude: list[str] | None, - ) -> tuple[object, ...]: - """Return scheduler keys that prevent overlapping Azure target execution.""" - - return self.preflight_execution( - target=target, - regions=configured_or_default_regions( - configured=target.regions, default=self.metadata.default_regions - ), - include=include, - exclude=exclude, - ).exclusive_execution_keys - def _resolve_discovered_subscriptions( self, *, @@ -630,6 +661,7 @@ def _execution_target( name=subscription_name, type="subscription", provider=self.metadata.name, + regions=list(locations), metadata={"subscription_id": subscription.subscription_id}, provider_data=data, ) diff --git a/src/anvil/providers/azure/session.py b/src/anvil/providers/azure/session.py deleted file mode 100644 index 7f11ebc..0000000 --- a/src/anvil/providers/azure/session.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Azure provider session factory exports.""" - -from anvil.providers.azure.provider import AzureSession, AzureSessionFactory - -__all__ = ["AzureSession", "AzureSessionFactory"] diff --git a/src/anvil/providers/base.py b/src/anvil/providers/base.py index 4c635ca..1d95563 100644 --- a/src/anvil/providers/base.py +++ b/src/anvil/providers/base.py @@ -1,11 +1,13 @@ from __future__ import annotations import inspect +from collections.abc import Callable from dataclasses import dataclass, field from typing import Protocol from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext +from anvil.regions import ALL_REGION_SELECTOR, is_region_selector from anvil.results import ExecutionStatus @@ -15,9 +17,9 @@ class ProviderMetadata: name: str display_name: str + supported_task_scopes: frozenset[str] description: str | None = None default_regions: tuple[str, ...] = () - supported_task_scopes: frozenset[str] = frozenset({"region"}) def configured_or_default_regions( @@ -41,6 +43,61 @@ def validate_resolved_regions(*, regions: list[str]) -> None: raise ValueError("regions must not contain duplicates") +def validate_string_options( + *, target: TargetDescriptor, allowed_options: frozenset[str] +) -> None: + """Validate a provider's supported string-valued options.""" + + unknown_options = sorted(set(target.provider_options) - allowed_options) + if unknown_options: + unknown_display = ", ".join(unknown_options) + allowed_display = ", ".join(sorted(allowed_options)) or "(none)" + raise ValueError( + f"Unsupported provider.options for provider '{target.provider}': " + f"{unknown_display}. Supported options: {allowed_display}" + ) + + for option_name, option_value in target.provider_options.items(): + if option_value is None: + continue + if not isinstance(option_value, str) or not option_value.strip(): + raise ValueError( + f"provider.options.{option_name} must be a non-empty string" + ) + + +def validate_region_selectors( + *, target: TargetDescriptor, selectors_allowed: bool +) -> None: + """Validate selector syntax according to a provider mode's capabilities.""" + + configured_regions = target.regions or [] + if ALL_REGION_SELECTOR in configured_regions and configured_regions != [ + ALL_REGION_SELECTOR + ]: + raise ValueError("regions selector 'all' must be the only region value") + + selectors = [region for region in configured_regions if is_region_selector(region)] + if selectors and not selectors_allowed: + raise ValueError( + f"provider '{target.provider}' mode '{target.mode}' requires explicit " + f"region names; selectors are not allowed: {', '.join(selectors)}" + ) + + +def narrow_include( + *, configured: list[str] | None, override: list[str] | None +) -> list[str] | None: + """Narrow an explicit configured target set with a CLI include override.""" + + if override is None: + return configured + if configured is None: + return override + allowed = set(configured) + return [target_id for target_id in override if target_id in allowed] + + @dataclass(frozen=True, slots=True) class ProviderAuthResult: """Provider-neutral authentication check result.""" @@ -68,6 +125,7 @@ class ExecutionTarget: name: str type: str provider: str + regions: list[str] metadata: dict[str, object] = field(default_factory=dict) provider_data: object | None = None @@ -77,8 +135,23 @@ class ProviderExecutionPlan: """Execution targets and provider-owned scheduling metadata.""" execution_targets: list[ExecutionTarget] - exclusive_execution_key: object | None = None - benchmark: dict[str, object] | None = None + + +@dataclass(frozen=True, slots=True) +class ProviderPreparation: + """Opaque provider preflight state and scheduler admission keys.""" + + data: object | None = None + exclusive_execution_keys: tuple[object, ...] = () + + +class ProviderPreparationCache(Protocol): + """Shared single-flight cache available during provider preparation.""" + + def get_or_create( + self, *, key: object, create: Callable[[], object] + ) -> tuple[object, bool, bool]: + """Return a cached or newly created preparation value.""" class ProviderExecutionRuntime(Protocol): @@ -104,6 +177,15 @@ class Provider(Protocol): def validate_target(self, target: TargetDescriptor) -> None: """Validate provider-specific target options.""" + def resolve_target_filters( + self, + *, + target: TargetDescriptor, + include_override: list[str] | None, + exclude_override: list[str] | None, + ) -> tuple[list[str] | None, list[str] | None]: + """Resolve provider-specific CLI filter semantics.""" + def auth_cache_key(self, target: TargetDescriptor) -> object | None: """Return a cache key for duplicate auth checks.""" @@ -113,6 +195,18 @@ def auth_check(self, target: TargetDescriptor) -> ProviderAuthResult: def discover_regions(self, target: TargetDescriptor) -> list[ProviderRegion]: """Discover provider regions or locations.""" + def prepare_target( + self, + *, + target: TargetDescriptor, + context: ExecutionContext, + include: list[str] | None, + exclude: list[str] | None, + cache: ProviderPreparationCache, + benchmark: dict[str, object] | None, + ) -> ProviderPreparation: + """Prepare provider-owned state before scheduler admission.""" + def resolve_execution_targets( self, *, @@ -120,6 +214,7 @@ def resolve_execution_targets( regions: list[str], include: list[str] | None, exclude: list[str] | None, + preparation: object | None = None, ) -> ProviderExecutionPlan: """Resolve one configured target into executable provider targets.""" @@ -147,9 +242,18 @@ def validate_provider_contract(provider: Provider) -> None: required_methods = { "validate_target": {"target"}, + "resolve_target_filters": {"target", "include_override", "exclude_override"}, "auth_cache_key": {"target"}, "auth_check": {"target"}, "discover_regions": {"target"}, + "prepare_target": { + "target", + "context", + "include", + "exclude", + "cache", + "benchmark", + }, "resolve_execution_targets": {"target", "regions", "include", "exclude"}, "prepare_execution_runtime": {"target", "execution_target", "context"}, } diff --git a/src/anvil/providers/gcp/provider.py b/src/anvil/providers/gcp/provider.py index c258f44..518f234 100644 --- a/src/anvil/providers/gcp/provider.py +++ b/src/anvil/providers/gcp/provider.py @@ -2,9 +2,9 @@ import threading from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace -from anvil.descriptors import ConfigBranch, MODE_GCP_ORGANIZATION, TargetDescriptor +from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -12,13 +12,24 @@ ProviderExecutionPlan, ProviderExecutionRuntime, ProviderMetadata, + ProviderPreparation, + ProviderPreparationCache, ProviderRegion, configured_or_default_regions, + narrow_include, + validate_region_selectors, + validate_string_options, ) from anvil.regions import is_region_selector, resolve_location_selectors from anvil.results import ExecutionStatus DEFAULT_REGIONS = ("us-central1",) +MODE_ORGANIZATION = "organization" +MODE_PROJECTS = "projects" +SUPPORTED_MODES = frozenset({MODE_ORGANIZATION, MODE_PROJECTS}) +SUPPORTED_OPTIONS = frozenset( + {"credentials_path", "organization_id", "quota_project_id"} +) GCP_AVAILABLE_REGION_STATUS = "UP" GCP_AVAILABLE_REGION_STATUSES = {GCP_AVAILABLE_REGION_STATUS} @@ -303,13 +314,52 @@ def validate_target(self, target: TargetDescriptor) -> None: ) if target.provider != self.metadata.name: raise ValueError("GCP provider supports provider 'gcp' targets only") + if target.mode not in SUPPORTED_MODES: + raise ValueError(f"Unsupported GCP target mode: {target.mode}") + validate_string_options(target=target, allowed_options=SUPPORTED_OPTIONS) + validate_region_selectors( + target=target, selectors_allowed=target.mode == MODE_PROJECTS + ) if target.include is not None and target.exclude is not None: raise ValueError("GCP include and exclude filters are mutually exclusive") + def resolve_target_filters( + self, + *, + target: TargetDescriptor, + include_override: list[str] | None, + exclude_override: list[str] | None, + ) -> tuple[list[str] | None, list[str] | None]: + """Apply GCP discovery overrides or narrow configured projects.""" + + if target.mode == MODE_ORGANIZATION or target.include is None: + include = ( + include_override if include_override is not None else target.include + ) + exclude = ( + exclude_override if exclude_override is not None else target.exclude + ) + else: + if exclude_override is not None: + raise ValueError( + "GCP projects with configured include do not allow --exclude" + ) + include = narrow_include( + configured=target.include, override=include_override + ) + exclude = None + + self.validate_target(replace(target, include=include, exclude=exclude)) + return include, exclude + def auth_cache_key(self, target: TargetDescriptor) -> object | None: """Return a provider auth cache identity without loading GCP SDKs.""" - return (self.metadata.name, target.profile) + return ( + self.metadata.name, + target.provider_options.get("credentials_path"), + target.provider_options.get("quota_project_id"), + ) def auth_check(self, target: TargetDescriptor) -> ProviderAuthResult: """Report deferred GCP auth checks without live SDK calls.""" @@ -331,6 +381,21 @@ def discover_regions(self, target: TargetDescriptor) -> list[ProviderRegion]: ) ] + def prepare_target( + self, + *, + target: TargetDescriptor, + context: ExecutionContext, + include: list[str] | None, + exclude: list[str] | None, + cache: ProviderPreparationCache, + benchmark: dict[str, object] | None, + ) -> ProviderPreparation: + """Return empty preflight state for GCP target resolution.""" + + self.validate_target(target) + return ProviderPreparation() + def resolve_execution_targets( self, *, @@ -338,12 +403,15 @@ def resolve_execution_targets( regions: list[str], include: list[str] | None, exclude: list[str] | None, + preparation: object | None = None, ) -> ProviderExecutionPlan: """Resolve GCP project IDs deterministically.""" self.validate_target(target) + if preparation is not None: + raise TypeError("GCP does not accept provider preparation data") - if target.mode == MODE_GCP_ORGANIZATION: + if target.mode == MODE_ORGANIZATION: raise NotImplementedError( "GCP organization discovery is not implemented yet. " "Use provider.mode 'projects' with include for explicit projects." @@ -410,6 +478,7 @@ def _execution_target( name=project_id, type="project", provider=self.metadata.name, + regions=list(locations), metadata={"project_id": project_id}, provider_data=data, ) diff --git a/src/anvil/providers/gcp/session.py b/src/anvil/providers/gcp/session.py deleted file mode 100644 index be7f2dd..0000000 --- a/src/anvil/providers/gcp/session.py +++ /dev/null @@ -1,5 +0,0 @@ -"""GCP provider session factory exports.""" - -from anvil.providers.gcp.provider import GcpSession, GcpSessionFactory - -__all__ = ["GcpSession", "GcpSessionFactory"] diff --git a/src/anvil/providers/github/provider.py b/src/anvil/providers/github/provider.py index 384a3e4..c19d165 100644 --- a/src/anvil/providers/github/provider.py +++ b/src/anvil/providers/github/provider.py @@ -11,18 +11,13 @@ import time import tomllib from collections.abc import Callable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from types import ModuleType from typing import Any from urllib.parse import urlparse -from anvil.descriptors import ( - ConfigBranch, - MODE_GITHUB_ORGANIZATIONS, - MODE_GITHUB_REPOSITORIES, - TargetDescriptor, -) +from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -30,12 +25,31 @@ ProviderExecutionPlan, ProviderExecutionRuntime, ProviderMetadata, + ProviderPreparation, + ProviderPreparationCache, ProviderRegion, configured_or_default_regions, + narrow_include, + validate_region_selectors, + validate_string_options, ) from anvil.results import ExecutionStatus __LOGGER__ = logging.getLogger(__name__) +MODE_ORGANIZATIONS = "organizations" +MODE_REPOSITORIES = "repositories" +SUPPORTED_MODES = frozenset({MODE_ORGANIZATIONS, MODE_REPOSITORIES}) +SUPPORTED_OPTIONS = frozenset( + { + "api_url", + "api_version", + "token_env", + "app_id", + "private_key_env", + "private_key_path", + "profile", + } +) DEFAULT_REGIONS = ("global",) DEFAULT_GITHUB_API_VERSION = "2022-11-28" @@ -1221,8 +1235,20 @@ def validate_target(self, target: TargetDescriptor) -> None: raise ValueError( "GitHub provider supports targets config (schema_version: 2) only" ) - if target.mode not in {MODE_GITHUB_ORGANIZATIONS, MODE_GITHUB_REPOSITORIES}: + if target.provider != self.metadata.name: + raise ValueError("GitHub provider supports provider 'github' targets only") + if target.mode not in SUPPORTED_MODES: raise ValueError(f"Unsupported GitHub target mode: {target.mode}") + validate_string_options(target=target, allowed_options=SUPPORTED_OPTIONS) + validate_region_selectors(target=target, selectors_allowed=False) + if ( + target.provider_options.get("profile") is not None + and len(target.provider_options) > 1 + ): + raise ValueError( + "GitHub provider.options.profile cannot be combined with inline " + "GitHub auth options" + ) if not target.include: raise ValueError( f"GitHub mode '{target.mode}' requires include with owner or " @@ -1233,6 +1259,21 @@ def validate_target(self, target: TargetDescriptor) -> None: self._validate_include_values(mode=target.mode, include=target.include) self._validate_code_search_isolation(target=target) + def resolve_target_filters( + self, + *, + target: TargetDescriptor, + include_override: list[str] | None, + exclude_override: list[str] | None, + ) -> tuple[list[str] | None, list[str] | None]: + """Narrow configured GitHub owners or repositories.""" + + if exclude_override is not None: + raise ValueError(f"GitHub mode '{target.mode}' does not allow --exclude") + include = narrow_include(configured=target.include, override=include_override) + self.validate_target(replace(target, include=include, exclude=None)) + return include, None + def auth_cache_key(self, target: TargetDescriptor) -> object | None: """Return a stable auth cache identity without importing PyGithub.""" @@ -1272,6 +1313,21 @@ def discover_regions(self, target: TargetDescriptor) -> list[ProviderRegion]: ) ] + def prepare_target( + self, + *, + target: TargetDescriptor, + context: ExecutionContext, + include: list[str] | None, + exclude: list[str] | None, + cache: ProviderPreparationCache, + benchmark: dict[str, object] | None, + ) -> ProviderPreparation: + """Return empty preflight state for GitHub target resolution.""" + + self.validate_target(target) + return ProviderPreparation() + def resolve_execution_targets( self, *, @@ -1279,14 +1335,17 @@ def resolve_execution_targets( regions: list[str], include: list[str] | None, exclude: list[str] | None, + preparation: object | None = None, ) -> ProviderExecutionPlan: """Resolve configured GitHub organizations or repositories.""" self.validate_target(target) + if preparation is not None: + raise TypeError("GitHub does not accept provider preparation data") if exclude is not None: raise ValueError(f"GitHub mode '{target.mode}' does not allow exclude") - if target.mode == MODE_GITHUB_ORGANIZATIONS: + if target.mode == MODE_ORGANIZATIONS: owner_logins = include or target.include or [] if _is_code_search_only_target(target=target): target_ids = owner_logins @@ -1305,6 +1364,7 @@ def resolve_execution_targets( self._execution_target( target_id=target_id, target_type=target_type, + regions=regions, provider_options=target.provider_options, ) for target_id in target_ids @@ -1362,7 +1422,12 @@ def prepare_execution_runtime( return GithubExecutionRuntime(data=execution_target.provider_data) def _execution_target( - self, *, target_id: str, target_type: str, provider_options: dict[str, object] + self, + *, + target_id: str, + target_type: str, + regions: list[str], + provider_options: dict[str, object], ) -> ExecutionTarget: data = GithubExecutionTargetData( target_id=target_id, @@ -1375,12 +1440,13 @@ def _execution_target( name=target_id, type=target_type, provider=self.metadata.name, + regions=list(regions), metadata={"github_target": target_id, "github_target_type": target_type}, provider_data=data, ) def _validate_include_values(self, *, mode: str | None, include: list[str]) -> None: - if mode == MODE_GITHUB_ORGANIZATIONS: + if mode == MODE_ORGANIZATIONS: invalid = [ target_id for target_id in include @@ -1428,11 +1494,6 @@ def create_provider_instance() -> GithubProvider: return GithubProvider() -GitHubExecutionTargetData = GithubExecutionTargetData -GitHubExecutionRuntime = GithubExecutionRuntime -GitHubProvider = GithubProvider - - def _is_not_found(error: Exception) -> bool: status = getattr(error, "status", None) data = getattr(error, "data", None) diff --git a/src/anvil/providers/github/session.py b/src/anvil/providers/github/session.py deleted file mode 100644 index c2a3bd6..0000000 --- a/src/anvil/providers/github/session.py +++ /dev/null @@ -1,5 +0,0 @@ -"""GitHub provider session factory exports.""" - -from anvil.providers.github.provider import GitHubSession, GitHubSessionFactory - -__all__ = ["GitHubSession", "GitHubSessionFactory"] diff --git a/src/anvil/result_query.py b/src/anvil/result_query.py index 2538697..7889560 100644 --- a/src/anvil/result_query.py +++ b/src/anvil/result_query.py @@ -18,6 +18,7 @@ "target", "entity_id", "entity_name", + "entity_metadata", "entity_type", "region", "task", @@ -25,6 +26,7 @@ ] FIELD_HEADERS = {"record_type": "type"} AVAILABLE_FIELDS = [ + "actions", "config_file", "config_file_resolved", "dry_run", @@ -36,6 +38,7 @@ "error", "generated_at", "record_type", + "provider", "region", "result", "started_at", @@ -94,6 +97,7 @@ def build_jsonl_records_for_target( **_timed_status_record(task_result), "result": task_result.result, "error": task_result.error, + "actions": list(task_result.actions), } ) @@ -342,6 +346,8 @@ def _base_entity_record( "entity_id": entity_result.id, "entity_name": entity_result.name, "entity_type": entity_result.type, + "provider": entity_result.provider, + "entity_metadata": dict(entity_result.metadata), } if config_file is not None: record["config_file"] = config_file.as_posix() diff --git a/src/anvil/results.py b/src/anvil/results.py index 68a7e10..a7f55ed 100644 --- a/src/anvil/results.py +++ b/src/anvil/results.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from anvil.descriptors import ConfigBranch @@ -56,6 +56,7 @@ class TaskResult(TimedResult): status: ExecutionStatus result: object | None = None error: str | None = None + actions: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, object]: return { @@ -67,6 +68,7 @@ def to_dict(self) -> dict[str, object]: "duration_seconds": self.duration_seconds, "result": self.result, "error": self.error, + "actions": list(self.actions), } @@ -106,6 +108,8 @@ class EntityResult(TimedResult): id: str name: str type: str + provider: str + metadata: dict[str, object] status: ExecutionStatus tasks: list[TaskResult] error: str | None = None @@ -116,6 +120,8 @@ def to_dict(self) -> dict[str, object]: "id": self.id, "name": self.name, "type": self.type, + "provider": self.provider, + "metadata": dict(self.metadata), "status": self.status.value, "started_at": self.started_at, "ended_at": self.ended_at, @@ -133,6 +139,7 @@ def to_dict(self) -> dict[str, object]: class TargetResult: config_branch: ConfigBranch target_name: str + provider: str generated_at: str dry_run: bool entities: list[EntityResult] @@ -166,6 +173,7 @@ def to_dict(self) -> dict[str, object]: payload: dict[str, object] = { singular_key: self.target_name, + "provider": self.provider, "generated_at": self.generated_at, "dry_run": self.dry_run, "total_entities": self.total_entities, @@ -183,6 +191,7 @@ def create( *, config_branch: ConfigBranch, target_name: str, + provider: str, dry_run: bool, entities: list[EntityResult], error: str | None = None, @@ -191,6 +200,7 @@ def create( return cls( config_branch=config_branch, target_name=target_name, + provider=provider, generated_at=datetime.datetime.now(datetime.UTC).isoformat(), dry_run=dry_run, entities=entities, diff --git a/src/anvil/runner.py b/src/anvil/runner.py index 6562db0..d30bab0 100644 --- a/src/anvil/runner.py +++ b/src/anvil/runner.py @@ -11,22 +11,14 @@ CancelledError, Future, ThreadPoolExecutor, - as_completed, wait, ) from dataclasses import dataclass, field, replace -from boto3.session import Session - from anvil.benchmark import BenchmarkRecorder -from anvil.account_resolver import AccountResolver from anvil.actions import ActionRecorder -from anvil.auth import AuthSource, auth_check, infer_auth_source from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext -from anvil.organization import OrganizationResolver -from anvil.providers.aws import AwsProvider -from anvil.providers.azure import AzureProvider from anvil.provider_loader import load_provider from anvil.providers.base import ( ExecutionTarget, @@ -45,7 +37,6 @@ TargetResult, TaskResult, ) -from anvil.session import SessionFactory from anvil.task_context import TaskCallContext from anvil.task_invocation import invoke_task from anvil.task_loader import ResolvedExecution, ResolvedTask, TaskScope, resolve_tasks @@ -128,20 +119,13 @@ def get_or_create( @dataclass(frozen=True, slots=True) class PreparedTarget: index: int + provider: Provider effective_target: TargetDescriptor auth_result: AuthResult context: ExecutionContext | None - session_factory: SessionFactory = field(default_factory=SessionFactory) provider_preflight: object | None = None preflight_error: str | None = None - exclusive_execution_key: object | None = None exclusive_execution_keys: tuple[object, ...] = () - base_session: Session | None = None - organization_id: str | None = None - management_account_id: str | None = None - base_session_account_id: str | None = None - discovered_accounts: dict[str, dict[str, str]] | None = None - region_statuses: dict[str, str] | None = None effective_include: list[str] | None = None effective_exclude: list[str] | None = None benchmark: dict[str, object] | None = None @@ -181,13 +165,8 @@ def __init__(self) -> None: self._cache = _SingleFlightCache() def get_or_check( - self, - *, - profile: str | None, - auth_source: AuthSource, - check: Callable[[], AuthCheckOutcome], + self, *, key: object, check: Callable[[], AuthCheckOutcome] ) -> _AuthCheckCacheLookup: - key = (profile, auth_source.value) outcome, hit, waited = self._cache.get_or_create(key=key, create=check) if not isinstance(outcome, AuthCheckOutcome): raise RuntimeError("Auth check cache returned unexpected value") @@ -195,34 +174,6 @@ def get_or_check( return _AuthCheckCacheLookup(outcome=outcome, hit=hit, waited=waited) -@dataclass(frozen=True, slots=True) -class OrganizationRunCacheEntry: - management_account_id: str - discovered_accounts: dict[str, dict[str, str]] - region_statuses: dict[str, str] - - -@dataclass(frozen=True, slots=True) -class _OrganizationRunCacheLookup: - entry: object - hit: bool - waited: bool - - -class OrganizationRunCache: - def __init__(self) -> None: - self._cache = _SingleFlightCache() - - def get_or_discover( - self, *, organization_id: str, discover: Callable[[], object] - ) -> _OrganizationRunCacheLookup: - entry, hit, waited = self._cache.get_or_create( - key=organization_id, create=discover - ) - - return _OrganizationRunCacheLookup(entry=entry, hit=hit, waited=waited) - - def _elevate_state(current: EngineState, new: EngineState) -> EngineState: """ Elevate engine state based on explicit precedence rules. @@ -275,26 +226,6 @@ def _auth_result_from_outcome( ) -def _run_cached_auth_check_for_target( - *, target: TargetDescriptor, auth_cache: AuthCheckCache -) -> AuthResult: - auth_source: AuthSource = infer_auth_source(target.profile) - - def check() -> AuthCheckOutcome: - return _auth_outcome_from_result( - auth_check( - target_name=target.name, profile=target.profile, auth_source=auth_source - ) - ) - - lookup = auth_cache.get_or_check( - profile=target.profile, auth_source=auth_source, check=check - ) - return _auth_result_from_outcome( - target_name=target.name, outcome=lookup.outcome, cached=lookup.hit - ) - - def _auth_result_from_provider_result( *, target_name: str, @@ -315,13 +246,15 @@ def _auth_result_from_provider_result( ) -def _run_provider_auth_check_for_target(*, target: TargetDescriptor) -> AuthResult: - provider = _load_provider(target.provider) +def _run_provider_auth_check_for_target( + *, provider: Provider, target: TargetDescriptor +) -> AuthResult: started_perf = time.perf_counter() started_at = datetime.datetime.now(datetime.UTC).isoformat() try: + provider.validate_target(target) provider_result = provider.auth_check(target) - except ValueError as error: + except Exception as error: ended_at = datetime.datetime.now(datetime.UTC).isoformat() return AuthResult( target_name=target.name, @@ -341,110 +274,35 @@ def _run_provider_auth_check_for_target(*, target: TargetDescriptor) -> AuthResu ) -def _run_dispatched_auth_check_for_target( - *, target: TargetDescriptor, auth_cache: AuthCheckCache +def _run_cached_provider_auth_check_for_target( + *, provider: Provider, target: TargetDescriptor, auth_cache: AuthCheckCache ) -> AuthResult: - if target.provider == "aws": - return _run_cached_auth_check_for_target(target=target, auth_cache=auth_cache) - - return _run_provider_auth_check_for_target(target=target) - - -def _resolve_effective_account_filters( - *, - target: TargetDescriptor, - cli_include: list[str] | None, - cli_exclude: list[str] | None, -) -> tuple[list[str] | None, list[str] | None]: - if target.config_branch is ConfigBranch.TARGETS and target.is_explicit_mode: - effective_exclude = cli_exclude if cli_exclude is not None else target.exclude - if cli_include is None: - return target.include, effective_exclude - - configured_target_ids = set(target.include or []) - narrowed_include = [ - target_id for target_id in cli_include if target_id in configured_target_ids - ] - return narrowed_include, effective_exclude - - if target.is_accounts_config: - if target.provider in {"azure", "gcp"}: - effective_exclude = ( - cli_exclude if cli_exclude is not None else target.exclude - ) - if cli_include is None: - return target.include, effective_exclude - if target.include is None: - return cli_include, effective_exclude - - configured_account_ids = set(target.include) - narrowed_include = [ - account_id - for account_id in cli_include - if account_id in configured_account_ids - ] - return narrowed_include, effective_exclude - - if cli_include is None: - return target.include, None - - configured_account_ids = set(target.include or []) - narrowed_include: list[str] = [ - account_id - for account_id in cli_include - if account_id in configured_account_ids - ] - return narrowed_include, None - - effective_include: list[str] | None = target.include - effective_exclude: list[str] | None = target.exclude + cache_key = provider.auth_cache_key(target) + if cache_key is None: + return _run_provider_auth_check_for_target(provider=provider, target=target) - if cli_include is not None: - effective_include: list[str] = cli_include - if cli_exclude is not None: - effective_exclude: list[str] = cli_exclude - - return effective_include, effective_exclude - - -def _validate_effective_account_filters( - *, target: TargetDescriptor, include: list[str] | None, exclude: list[str] | None -) -> None: - if ( - target.config_branch is ConfigBranch.TARGETS - and target.is_explicit_mode - and not ( - target.provider == "gcp" - and target.mode == "projects" - and target.include is None - ) - and exclude is not None - ): - raise ValueError( - f"Target '{target.name}' provider '{target.provider}' mode " - f"'{target.mode}' does not allow exclude; explicit modes require include." + def check() -> AuthCheckOutcome: + return _auth_outcome_from_result( + _run_provider_auth_check_for_target(provider=provider, target=target) ) - if include is not None and exclude is not None: - raise ValueError( - f"Target '{target.name}' cannot use include and exclude together; " - "they are mutually exclusive for all providers and modes." - ) + lookup = auth_cache.get_or_check(key=cache_key, check=check) + return _auth_result_from_outcome( + target_name=target.name, outcome=lookup.outcome, cached=lookup.hit + ) def _build_effective_target( *, + provider: Provider, target: TargetDescriptor, cli_dry_run: bool | None, cli_include: list[str] | None, cli_exclude: list[str] | None, ) -> TargetDescriptor: effective_dry_run: bool = cli_dry_run if cli_dry_run is not None else target.dry_run - effective_include, effective_exclude = _resolve_effective_account_filters( - target=target, cli_include=cli_include, cli_exclude=cli_exclude - ) - _validate_effective_account_filters( - target=target, include=effective_include, exclude=effective_exclude + effective_include, effective_exclude = provider.resolve_target_filters( + target=target, include_override=cli_include, exclude_override=cli_exclude ) return replace( @@ -477,7 +335,6 @@ def _build_execution_context( raise ValueError("execution context requires resolved regions") return ExecutionContext( regions=target.regions, - role_name=target.role_name, dry_run=target.dry_run, tasks=tasks, metadata=target.metadata, @@ -494,46 +351,45 @@ def prepare_target( cli_dry_run: bool | None, cli_include: list[str] | None, cli_exclude: list[str] | None, - organization_cache: OrganizationRunCache, + preparation_cache: _SingleFlightCache, auth_cache: AuthCheckCache, benchmark_enabled: bool = False, ) -> PreparedTarget: recorder = BenchmarkRecorder(enabled=benchmark_enabled) - session_factory = SessionFactory() - with recorder.phase("prepare_target_seconds"): provider = _load_provider(target.provider) - auth_result: AuthResult = _run_dispatched_auth_check_for_target( - target=target, auth_cache=auth_cache - ) try: effective_target: TargetDescriptor = _build_effective_target( + provider=provider, target=target, cli_dry_run=cli_dry_run, cli_include=cli_include, cli_exclude=cli_exclude, ) - effective_include, effective_exclude = _resolve_effective_account_filters( - target=target, cli_include=cli_include, cli_exclude=cli_exclude - ) except ValueError as error: return PreparedTarget( index=index, + provider=provider, effective_target=target, auth_result=_auth_result_from_config_error(target=target, error=error), context=None, - session_factory=session_factory, benchmark=recorder.data, ) + effective_include = effective_target.include + effective_exclude = effective_target.exclude + auth_result: AuthResult = _run_cached_provider_auth_check_for_target( + provider=provider, target=effective_target, auth_cache=auth_cache + ) + if auth_result.is_error: return PreparedTarget( index=index, + provider=provider, effective_target=effective_target, auth_result=auth_result, context=None, - session_factory=session_factory, effective_include=effective_include, effective_exclude=effective_exclude, benchmark=recorder.data, @@ -560,73 +416,31 @@ def prepare_target( provider_preflight: object | None = None preflight_error: str | None = None - exclusive_execution_key: object | None = None exclusive_execution_keys: tuple[object, ...] = () - base_session: Session | None = None - organization_id: str | None = None - management_account_id: str | None = None - base_session_account_id: str | None = None - discovered_accounts: dict[str, dict[str, str]] | None = None - region_statuses: dict[str, str] | None = None - if ( - effective_target.provider == "aws" - and effective_target.is_organization_config - ): - if not isinstance(provider, AwsProvider): - raise TypeError("AWS target resolved to a non-AWS provider") - - preflight_result = provider.preflight_execution( + try: + preparation = provider.prepare_target( target=effective_target, context=context, - session_factory=session_factory, - organization_cache=organization_cache, + include=effective_include, + exclude=effective_exclude, + cache=preparation_cache, benchmark=recorder.data, - organization_resolver_cls=OrganizationResolver, ) - provider_preflight = preflight_result.data - exclusive_execution_key = preflight_result.exclusive_execution_key - base_session = preflight_result.data.base_session - organization_id = preflight_result.data.organization_id - management_account_id = preflight_result.data.management_account_id - base_session_account_id = preflight_result.data.base_session_account_id - discovered_accounts = preflight_result.data.discovered_accounts - region_statuses = preflight_result.data.region_statuses - elif effective_target.provider == "azure": - if not isinstance(provider, AzureProvider): - raise TypeError("Azure target resolved to a non-Azure provider") - - try: - preflight_result = provider.preflight_execution( - target=effective_target, - regions=context.regions, - include=effective_include, - exclude=effective_exclude, - benchmark=recorder.data, - ) - except (RuntimeError, ValueError) as error: - provider_preflight = None - preflight_error = str(error) - exclusive_execution_keys = () - else: - provider_preflight = preflight_result.data - exclusive_execution_keys = preflight_result.exclusive_execution_keys + except Exception as error: + preflight_error = str(error) + else: + provider_preflight = preparation.data + exclusive_execution_keys = preparation.exclusive_execution_keys return PreparedTarget( index=index, + provider=provider, effective_target=effective_target, auth_result=auth_result, context=context, - session_factory=session_factory, provider_preflight=provider_preflight, preflight_error=preflight_error, - exclusive_execution_key=exclusive_execution_key, exclusive_execution_keys=exclusive_execution_keys, - base_session=base_session, - organization_id=organization_id, - management_account_id=management_account_id, - base_session_account_id=base_session_account_id, - discovered_accounts=discovered_accounts, - region_statuses=region_statuses, effective_include=effective_include, effective_exclude=effective_exclude, benchmark=recorder.data, @@ -649,14 +463,8 @@ def _task_order(context: ExecutionContext) -> dict[str, int]: def _execution_target_regions( *, execution_target: ExecutionTarget, context: ExecutionContext ) -> list[str]: - provider_data = execution_target.provider_data - locations = getattr(provider_data, "locations", None) - if isinstance(locations, list) and all( - isinstance(location, str) for location in locations - ): - return [location for location in locations if isinstance(location, str)] - - return list(context.regions) + validate_resolved_regions(regions=execution_target.regions) + return list(execution_target.regions) def _execute_provider_region( @@ -666,14 +474,11 @@ def _execute_provider_region( context: ExecutionContext, region: str, target_cancel_event: threading.Event, - actions: ActionRecorder | None = None, tasks: list[ResolvedTask] | None = None, dependency_results: dict[str, TaskResult] | None = None, ) -> _ProviderRegionOutcome: region_started = time.perf_counter() session = runtime.build_session(region=region) - if actions is None: - actions = ActionRecorder(actions=[]) task_results: list[TaskResult] = [] region_task_results: dict[str, TaskResult] = dict(dependency_results or {}) optional_map = {task.name: task.optional for task in context.tasks} @@ -708,6 +513,7 @@ def _execute_provider_region( task_started_perf = time.perf_counter() task_started_at = datetime.datetime.now(datetime.UTC).isoformat() + actions = ActionRecorder(actions=[]) try: task_context = TaskCallContext( provider=execution_target.provider, @@ -732,6 +538,7 @@ def _execute_provider_region( ended_at=task_ended_at, duration_seconds=task_ended_perf - task_started_perf, error=str(error), + actions=list(actions.actions), ) region_task_results[task.name] = task_result task_results.append(task_result) @@ -749,6 +556,7 @@ def _execute_provider_region( ended_at=task_ended_at, duration_seconds=task_ended_perf - task_started_perf, result=result, + actions=list(actions.actions), ) region_task_results[task.name] = task_result task_results.append(task_result) @@ -808,7 +616,6 @@ def _execute_provider_execution_target( context=context, region=regions[0], target_cancel_event=threading.Event(), - actions=ActionRecorder(actions=[]), tasks=target_tasks, ) target_execution_seconds = time.perf_counter() - target_started @@ -884,6 +691,8 @@ def _execute_provider_execution_target( id=execution_target.id, name=execution_target.name, type=execution_target.type, + provider=execution_target.provider, + metadata=dict(execution_target.metadata), status=status, started_at=started_at, ended_at=ended_at, @@ -972,8 +781,6 @@ def _execute_provider_regions_sequential( dependency_results: dict[str, TaskResult] | None = None, ) -> list[_ProviderRegionOutcome]: region_outcomes: list[_ProviderRegionOutcome] = [] - actions = ActionRecorder(actions=[]) - for region in regions: outcome = _execute_provider_region( execution_target=execution_target, @@ -981,7 +788,6 @@ def _execute_provider_regions_sequential( context=context, region=region, target_cancel_event=target_cancel_event, - actions=actions, tasks=tasks, dependency_results=dependency_results, ) @@ -1070,37 +876,47 @@ def _execute_provider_targets( recorder = BenchmarkRecorder(data=benchmark_data) with recorder.phase("entity_execution_seconds"): + pending_targets = deque(execution_targets) with ThreadPoolExecutor(max_workers=target.max_workers) as executor: - futures: dict[Future[EntityResult], ExecutionTarget] = { - executor.submit( - _execute_provider_execution_target, - provider=provider, - target=target, - execution_target=execution_target, - context=context, - ): execution_target - for execution_target in execution_targets - } - fail_fast_triggered = False + active_futures: dict[Future[EntityResult], ExecutionTarget] = {} try: - for future in as_completed(futures): - try: - entity_result = future.result() - except CancelledError: - continue - - entity_results.append(entity_result) - if ( - context.fail_fast - and entity_result.status.is_unsuccessful - and not fail_fast_triggered + while pending_targets or active_futures: + while ( + pending_targets + and not context.cancel_event.is_set() + and len(active_futures) < target.max_workers ): - context.cancel_event.set() - fail_fast_triggered = True - for pending in futures: - if not pending.done(): - pending.cancel() + execution_target = pending_targets.popleft() + future = executor.submit( + _execute_provider_execution_target, + provider=provider, + target=target, + execution_target=execution_target, + context=context, + ) + active_futures[future] = execution_target + + if not active_futures: + break + + completed, _ = wait(active_futures, return_when=FIRST_COMPLETED) + for future in completed: + active_futures.pop(future) + if future.cancelled(): + continue + + try: + entity_result = future.result() + except CancelledError: + continue + + entity_results.append(entity_result) + if context.fail_fast and entity_result.status.is_unsuccessful: + context.cancel_event.set() + pending_targets.clear() + for active_future in active_futures: + active_future.cancel() except Exception: executor.shutdown(cancel_futures=True) raise @@ -1138,6 +954,7 @@ def _execute_provider_targets( return TargetResult.create( config_branch=target.config_branch, target_name=target.name, + provider=target.provider, dry_run=context.dry_run, entities=entity_results, benchmark=recorder.data, @@ -1181,6 +998,7 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu target_result=TargetResult.create( config_branch=target.config_branch, target_name=target.name, + provider=target.provider, dry_run=context.dry_run, entities=[], error=prepared_target.preflight_error, @@ -1195,40 +1013,16 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu ) sink = BenchmarkRecorder(data=benchmark_data) - provider = _load_provider(target.provider) + provider = prepared_target.provider try: with sink.phase("resolve_execution_targets_seconds"): - if target.provider == "aws": - if not isinstance(provider, AwsProvider): - raise TypeError("AWS target resolved to a non-AWS provider") - - execution_plan = provider.resolve_execution_targets( - target=target, - regions=context.regions, - include=prepared_target.effective_include, - exclude=prepared_target.effective_exclude, - preflight_data=prepared_target.provider_preflight, - organization_resolver_cls=OrganizationResolver, - account_resolver_cls=AccountResolver, - ) - elif target.provider == "azure": - if not isinstance(provider, AzureProvider): - raise TypeError("Azure target resolved to a non-Azure provider") - - execution_plan = provider.resolve_execution_targets( - target=target, - regions=context.regions, - include=prepared_target.effective_include, - exclude=prepared_target.effective_exclude, - preflight_data=prepared_target.provider_preflight, - ) - else: - execution_plan = provider.resolve_execution_targets( - target=target, - regions=context.regions, - include=prepared_target.effective_include, - exclude=prepared_target.effective_exclude, - ) + execution_plan = provider.resolve_execution_targets( + target=target, + regions=context.regions, + include=prepared_target.effective_include, + exclude=prepared_target.effective_exclude, + preparation=prepared_target.provider_preflight, + ) sink.update( { "resolved_execution_target_count": len( @@ -1246,12 +1040,10 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu context=context, ) except Exception as error: - if target.provider == "aws" and not isinstance(error, ValueError): - raise - target_result = TargetResult.create( config_branch=target.config_branch, target_name=target.name, + provider=target.provider, dry_run=context.dry_run, entities=[], error=str(error), @@ -1285,13 +1077,7 @@ def _next_eligible_target( def _prepared_target_execution_keys( prepared_target: PreparedTarget, ) -> tuple[object, ...]: - if prepared_target.exclusive_execution_keys: - return prepared_target.exclusive_execution_keys - - execution_key = ( - prepared_target.exclusive_execution_key or prepared_target.organization_id - ) - return () if execution_key is None else (execution_key,) + return prepared_target.exclusive_execution_keys def run_auth_checks(*, targets: list[TargetDescriptor]) -> EngineResult: @@ -1309,7 +1095,8 @@ def run_auth_checks(*, targets: list[TargetDescriptor]) -> EngineResult: ) as executor: futures: list[Future[AuthResult]] = [ executor.submit( - _run_dispatched_auth_check_for_target, + _run_cached_provider_auth_check_for_target, + provider=_load_provider(target.provider), target=target, auth_cache=auth_cache, ) @@ -1348,7 +1135,7 @@ def _run_target_pipeline( # same-organization exclusion has cleared. ready_targets: deque[PreparedTarget] = deque() active_execution_keys: set[object] = set() - organization_cache = OrganizationRunCache() + preparation_cache = _SingleFlightCache() auth_cache = AuthCheckCache() if targets: @@ -1366,7 +1153,7 @@ def _run_target_pipeline( cli_dry_run=cli_dry_run, cli_include=cli_include, cli_exclude=cli_exclude, - organization_cache=organization_cache, + preparation_cache=preparation_cache, auth_cache=auth_cache, benchmark_enabled=benchmark_enabled, ): index diff --git a/src/anvil/schemas/common.schema.v2.json b/src/anvil/schemas/common.schema.v2.json index 3cbbd10..e6bf071 100644 --- a/src/anvil/schemas/common.schema.v2.json +++ b/src/anvil/schemas/common.schema.v2.json @@ -14,7 +14,7 @@ "name": { "type": "string", "minLength": 1, - "description": "Task name (must match task module filename)." + "description": "Task name (must match the discovered task component name)." }, "depends_on": { "type": "array", @@ -62,40 +62,16 @@ } } }, - "profile": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ], - "description": "Provider profile for the source authentication context when used under provider.options." - }, "regions": { "type": "array", "minItems": 1, "uniqueItems": true, - "description": "Provider regions/locations for task execution. AWS organization configs also support the 'all' selector and glob selectors.", + "description": "Provider regions or locations for task execution.", "items": { "type": "string", "minLength": 1 } }, - "role_name": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "null" - } - ], - "description": "Optional AWS IAM role name to assume in target accounts when used under provider.options. AWS organizations default to OrganizationAccountAccessRole at runtime; AWS accounts run directly when omitted." - }, "tasks": { "type": "array", "minItems": 1, @@ -145,161 +121,6 @@ "additionalProperties": true, "description": "Free-form metadata for execution context." }, - "provider": { - "type": "string", - "enum": [ - "aws", - "azure", - "gcp", - "github" - ], - "default": "aws", - "description": "Cloud provider for this target. Provider name is required under provider.name." - }, - "mode": { - "type": "string", - "enum": [ - "organization", - "accounts", - "tenant", - "subscriptions", - "projects", - "organizations", - "repositories" - ], - "description": "Provider-specific target mode. Provider mode is required under provider.mode." - }, - "awsProviderOptions": { - "type": "object", - "additionalProperties": false, - "properties": { - "profile": { - "$ref": "#/$defs/profile" - }, - "role_name": { - "$ref": "#/$defs/role_name" - } - } - }, - "azureProviderOptions": { - "type": "object", - "additionalProperties": false, - "properties": { - "tenant_id": { - "type": "string", - "minLength": 1 - }, - "client_id": { - "type": "string", - "minLength": 1 - }, - "client_secret": { - "type": "string", - "minLength": 1 - } - } - }, - "gcpProviderOptions": { - "type": "object", - "additionalProperties": false, - "properties": { - "credentials_path": { - "type": "string", - "minLength": 1 - }, - "organization_id": { - "type": "string", - "minLength": 1 - }, - "quota_project_id": { - "type": "string", - "minLength": 1 - } - } - }, - "githubProviderOptions": { - "type": "object", - "additionalProperties": false, - "allOf": [ - { - "not": { - "required": [ - "profile", - "api_url" - ] - } - }, - { - "not": { - "required": [ - "profile", - "api_version" - ] - } - }, - { - "not": { - "required": [ - "profile", - "token_env" - ] - } - }, - { - "not": { - "required": [ - "profile", - "app_id" - ] - } - }, - { - "not": { - "required": [ - "profile", - "private_key_env" - ] - } - }, - { - "not": { - "required": [ - "profile", - "private_key_path" - ] - } - } - ], - "properties": { - "profile": { - "$ref": "#/$defs/profile" - }, - "api_url": { - "type": "string", - "minLength": 1 - }, - "api_version": { - "type": "string", - "minLength": 1 - }, - "token_env": { - "type": "string", - "minLength": 1 - }, - "app_id": { - "type": "string", - "minLength": 1 - }, - "private_key_env": { - "type": "string", - "minLength": 1 - }, - "private_key_path": { - "type": "string", - "minLength": 1 - } - } - }, "post_run": { "type": "array", "minItems": 1, @@ -308,15 +129,6 @@ "$ref": "#/$defs/postRunEntry" } }, - "accountIdList": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[0-9]{12}$" - }, - "description": "AWS account ID list." - }, "targetIdList": { "type": "array", "minItems": 1, @@ -325,27 +137,7 @@ "minLength": 1 }, "uniqueItems": true, - "description": "Provider-specific execution target ID list. AWS uses account IDs, Azure uses subscription IDs, GCP uses project IDs, and GitHub uses organization logins or owner/repo names." - }, - "githubOrganizationList": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$" - }, - "uniqueItems": true, - "description": "GitHub owner login list. Values may be organization or user logins." - }, - "githubRepositoryList": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[^/\\s]+$" - }, - "uniqueItems": true, - "description": "GitHub owner/repo repository list." + "description": "Provider-owned execution target ID list." }, "baseTargetProperties": { "name": { diff --git a/src/anvil/schemas/targets.schema.v2.json b/src/anvil/schemas/targets.schema.v2.json index d1a2369..1040445 100644 --- a/src/anvil/schemas/targets.schema.v2.json +++ b/src/anvil/schemas/targets.schema.v2.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://anvil.local/schemas/targets.schema.v2.json", "title": "Anvil Targets Configuration v2", - "description": "Declarative multi-cloud configuration for provider targets.", + "description": "Declarative provider-aware configuration for Anvil targets.", "type": "object", "required": [ "schema_version", @@ -43,29 +43,19 @@ "properties": { "name": { "type": "string", - "enum": [ - "aws", - "azure", - "gcp", - "github" - ] + "minLength": 1, + "description": "Discovered provider component name." }, "mode": { "type": "string", - "enum": [ - "organization", - "accounts", - "tenant", - "subscriptions", - "projects", - "organizations", - "repositories" - ] + "minLength": 1, + "description": "Provider-owned target mode." }, "options": { "type": "object", "default": {}, - "additionalProperties": true + "additionalProperties": true, + "description": "Provider-owned configuration options." } } }, @@ -100,389 +90,12 @@ "$ref": "common.schema.v2.json#/$defs/targetIdList" } }, - "allOf": [ - { - "not": { - "required": [ - "include", - "exclude" - ] - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "aws" - }, - "mode": { - "const": "organization" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "properties": { - "include": { - "$ref": "common.schema.v2.json#/$defs/accountIdList" - }, - "exclude": { - "$ref": "common.schema.v2.json#/$defs/accountIdList" - }, - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/awsProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "aws" - }, - "mode": { - "const": "accounts" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "required": [ - "include" - ], - "not": { - "required": [ - "exclude" - ] - }, - "properties": { - "include": { - "$ref": "common.schema.v2.json#/$defs/accountIdList" - }, - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/awsProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "azure" - }, - "mode": { - "const": "tenant" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "properties": { - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/azureProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "azure" - }, - "mode": { - "const": "subscriptions" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "required": [ - "include" - ], - "not": { - "required": [ - "exclude" - ] - }, - "properties": { - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/azureProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "gcp" - }, - "mode": { - "const": "organization" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "properties": { - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/gcpProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "gcp" - }, - "mode": { - "const": "projects" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "required": [ - "include" - ], - "not": { - "required": [ - "exclude" - ] - }, - "properties": { - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/gcpProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "github" - }, - "mode": { - "const": "organizations" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "required": [ - "include" - ], - "not": { - "required": [ - "exclude" - ] - }, - "properties": { - "include": { - "$ref": "common.schema.v2.json#/$defs/githubOrganizationList" - }, - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/githubProviderOptions" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "properties": { - "name": { - "const": "github" - }, - "mode": { - "const": "repositories" - } - }, - "required": [ - "name", - "mode" - ] - } - } - }, - "then": { - "required": [ - "include" - ], - "not": { - "required": [ - "exclude" - ] - }, - "properties": { - "include": { - "$ref": "common.schema.v2.json#/$defs/githubRepositoryList" - }, - "provider": { - "properties": { - "options": { - "$ref": "common.schema.v2.json#/$defs/githubProviderOptions" - } - } - } - } - } - } - ], - "anyOf": [ - { - "properties": { - "provider": { - "properties": { - "name": { - "const": "aws" - }, - "mode": { - "enum": [ - "organization", - "accounts" - ] - } - } - } - } - }, - { - "properties": { - "provider": { - "properties": { - "name": { - "const": "azure" - }, - "mode": { - "enum": [ - "tenant", - "subscriptions" - ] - } - } - } - } - }, - { - "properties": { - "provider": { - "properties": { - "name": { - "const": "gcp" - }, - "mode": { - "enum": [ - "organization", - "projects" - ] - } - } - } - } - }, - { - "properties": { - "provider": { - "properties": { - "name": { - "const": "github" - }, - "mode": { - "enum": [ - "organizations", - "repositories" - ] - } - } - } - } - } - ] + "not": { + "required": [ + "include", + "exclude" + ] + } } } } diff --git a/src/anvil/task_context.py b/src/anvil/task_context.py index fb82848..e4e3e32 100644 --- a/src/anvil/task_context.py +++ b/src/anvil/task_context.py @@ -32,5 +32,4 @@ def to_kwargs(self) -> dict[str, object]: "dry_run": self.dry_run, "metadata": self.metadata, "actions": self.actions, - "task_context": self, } diff --git a/src/anvil/task_invocation.py b/src/anvil/task_invocation.py index 58c903b..9e06960 100644 --- a/src/anvil/task_invocation.py +++ b/src/anvil/task_invocation.py @@ -1,28 +1,10 @@ from __future__ import annotations from collections.abc import Callable -from inspect import Parameter, signature - from anvil.task_context import TaskCallContext def invoke_task(task_run: Callable, *, context: TaskCallContext) -> object: """Invoke a task with provider-neutral kwargs.""" - candidate_kwargs = context.to_kwargs() - try: - run_signature = signature(task_run) - except TypeError, ValueError: - return task_run(**candidate_kwargs) - - parameters = run_signature.parameters - accepts_extra_kwargs = any( - parameter.kind is Parameter.VAR_KEYWORD for parameter in parameters.values() - ) - if accepts_extra_kwargs: - return task_run(**candidate_kwargs) - - accepted_kwargs = { - name: value for name, value in candidate_kwargs.items() if name in parameters - } - return task_run(**accepted_kwargs) + return task_run(**context.to_kwargs()) diff --git a/src/anvil/task_loader.py b/src/anvil/task_loader.py index 8d01862..3c52bc9 100644 --- a/src/anvil/task_loader.py +++ b/src/anvil/task_loader.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import importlib.util import sys from collections import defaultdict, deque from collections.abc import Callable, Mapping, Sequence @@ -46,13 +47,16 @@ class ResolvedTask: scope: TaskScope = TaskScope.REGION -@dataclass(slots=True) -class _TaskSpec: - depends_on: list[str] +@dataclass(frozen=True, slots=True) +class TaskSpec: + """Immutable normalized task declaration.""" + + name: str + depends_on: tuple[str, ...] optional: bool -TaskSpecKey = tuple[tuple[str, tuple[str, ...], bool], ...] +TaskSpecKey = tuple[TaskSpec, ...] CachedOrderedTask = tuple[tuple[str, Callable, tuple[str, ...], bool, TaskScope], ...] CachedAdjacency = tuple[tuple[str, tuple[str, ...]], ...] @@ -61,13 +65,7 @@ class TaskConfigError(RuntimeError): pass -@dataclass(frozen=True, slots=True) -class TaskDescriptor: - """Discovered task and lazy loader for its run callable.""" - - name: str - load: Callable[[], Callable] - source: str +TaskDescriptor = CatalogDescriptor[Callable] @dataclass(frozen=True, slots=True) @@ -113,34 +111,28 @@ def _load_task_component( @lru_cache(maxsize=512) def _load_provider_task_callable(*, provider_name: str, task_name: str) -> Callable: - descriptors = _provider_task_descriptor_index(provider_name).get(task_name, []) + descriptor_index, discovery_issues = _provider_task_discovery(provider_name) + descriptors = descriptor_index.get(task_name, []) if not descriptors: + issue_detail = "" + if discovery_issues: + issue_lines = "; ".join( + f"{issue.name} ({issue.source}): {issue.error}" + for issue in discovery_issues + ) + issue_detail = ( + f" Discovery of one or more task sources failed: {issue_lines}" + ) raise TaskConfigError( f"Task '{task_name}' is not available for provider '{provider_name}'. " "Tasks must be provided by universal package 'anvil.providers.tasks' " f"or provider package 'anvil.providers.{provider_name}.tasks'." + f"{issue_detail}" ) - catalog_descriptors = [ - CatalogDescriptor( - name=descriptor.name, - source=ComponentSource( - origin=( - ComponentOrigin.PLUGIN - if "plugin:" in descriptor.source - else ComponentOrigin.STOCK - ), - package="", - label=descriptor.source, - provider=provider_name, - ), - load=descriptor.load, - ) - for descriptor in descriptors - ] return ComponentResolver( kind=ComponentKind.TASK, - catalog=ComponentCatalog(descriptors=tuple(catalog_descriptors)), + catalog=ComponentCatalog(descriptors=tuple(descriptors)), error_type=TaskConfigError, context=f"for provider '{provider_name}'", ).load(task_name) @@ -168,6 +160,22 @@ def _provider_task_entry_point_groups( def _iter_package_task_descriptors( *, package_name: str, source: str ) -> list[TaskDescriptor]: + try: + package_spec = importlib.util.find_spec(package_name) + except ModuleNotFoundError as error: + missing_package = error.name + if missing_package is not None and ( + package_name == missing_package + or package_name.startswith(f"{missing_package}.") + ): + return [] + raise TaskConfigError( + f"Unable to inspect task package '{package_name}': {error}" + ) from error + + if package_spec is None: + return [] + component_source = ComponentSource( origin=ComponentOrigin.STOCK, package=package_name, @@ -182,15 +190,8 @@ def _iter_package_task_descriptors( ).discover() if issues: issue = issues[0] - if "No module named" in issue.error and package_name in issue.error: - return [] raise TaskConfigError(f"{issue.name} ({issue.source}): {issue.error}") - return [ - TaskDescriptor( - name=descriptor.name, load=descriptor.load, source=str(descriptor.source) - ) - for descriptor in descriptors - ] + return list(descriptors) def _iter_plugin_task_descriptors( @@ -220,14 +221,7 @@ def _iter_plugin_task_descriptors( source=component_source, component_loader=_load_task_component, ).discover(issue_name=entry_point.name) - descriptors.extend( - TaskDescriptor( - name=descriptor.name, - load=descriptor.load, - source=str(descriptor.source), - ) - for descriptor in discovered - ) + descriptors.extend(discovered) issues.extend(source_issues) return descriptors, issues @@ -238,52 +232,22 @@ def _provider_task_discovery( provider_name: str, ) -> tuple[dict[str, tuple[TaskDescriptor, ...]], tuple[DiscoveryIssue, ...]]: catalog_descriptors: list[CatalogDescriptor[Callable]] = [] - public_descriptors: dict[int, TaskDescriptor] = {} issues: list[DiscoveryIssue] = [] for source, package_name in _provider_task_packages(provider_name): - for descriptor in _iter_package_task_descriptors( - package_name=package_name, source=source - ): - catalog_descriptor = CatalogDescriptor( - name=descriptor.name, - source=ComponentSource( - origin=ComponentOrigin.STOCK, - package=package_name, - label=descriptor.source, - provider=None if source == "universal" else provider_name, - ), - load=descriptor.load, - ) - catalog_descriptors.append(catalog_descriptor) - public_descriptors[id(catalog_descriptor)] = descriptor + catalog_descriptors.extend( + _iter_package_task_descriptors(package_name=package_name, source=source) + ) for source, entry_point_group in _provider_task_entry_point_groups(provider_name): plugin_descriptors, plugin_issues = _iter_plugin_task_descriptors( entry_point_group=entry_point_group, source_prefix=f"{source} plugin:" ) issues.extend(plugin_issues) - for descriptor in plugin_descriptors: - catalog_descriptor = CatalogDescriptor( - name=descriptor.name, - source=ComponentSource( - origin=ComponentOrigin.PLUGIN, - package="", - label=descriptor.source, - entry_point_group=entry_point_group, - provider=None if source == "universal" else provider_name, - ), - load=descriptor.load, - ) - catalog_descriptors.append(catalog_descriptor) - public_descriptors[id(catalog_descriptor)] = descriptor + catalog_descriptors.extend(plugin_descriptors) - catalog = ComponentCatalog(descriptors=tuple(catalog_descriptors)) - index = { - name: tuple(public_descriptors[id(descriptor)] for descriptor in descriptors) - for name, descriptors in catalog.inventory.items() - } - return index, tuple(issues) + catalog = ComponentCatalog.build(catalog_descriptors, issues) + return dict(catalog.inventory), catalog.issues def _provider_task_descriptor_index( @@ -305,6 +269,7 @@ def provider_task_descriptor_index( def _clear_task_caches() -> None: _load_provider_task_callable.cache_clear() + _resolve_tasks_cached.cache_clear() cache_clear = getattr(_provider_task_discovery, "cache_clear", None) if cache_clear is not None: cache_clear() @@ -318,13 +283,19 @@ def _clear_task_caches() -> None: TaskSpecInput = Mapping[str, object] -def _freeze_task_specs(task_specs: Sequence[TaskSpecInput]) -> TaskSpecKey: - frozen_specs: list[tuple[str, tuple[str, ...], bool]] = [] +def _normalize_task_specs(task_specs: Sequence[TaskSpecInput]) -> TaskSpecKey: + """Validate task declarations once while preserving declaration order.""" + + normalized_specs: list[TaskSpec] = [] + seen_names: set[str] = set() for spec in task_specs: - name = spec["name"] - if not isinstance(name, str): - raise TaskConfigError("task name must be a string") + name = spec.get("name") + if not isinstance(name, str) or not name: + raise TaskConfigError("task name must be a non-empty string") + if name in seen_names: + raise TaskConfigError(f"Duplicate task name detected: '{name}'") + seen_names.add(name) raw_depends_on = spec.get("depends_on", []) if not isinstance(raw_depends_on, list): @@ -336,12 +307,20 @@ def _freeze_task_specs(task_specs: Sequence[TaskSpecInput]) -> TaskSpecKey: f"Task '{name}' depends_on must be a list of strings" ) depends_on.append(dependency) + if len(set(depends_on)) != len(depends_on): + raise TaskConfigError( + f"Task '{name}' depends_on must not contain duplicates" + ) + + optional = spec.get("optional", False) + if not isinstance(optional, bool): + raise TaskConfigError(f"Task '{name}' optional must be a boolean") - frozen_specs.append( - (name, tuple(depends_on), bool(spec.get("optional", False))) + normalized_specs.append( + TaskSpec(name=name, depends_on=tuple(depends_on), optional=optional) ) - return tuple(frozen_specs) + return tuple(normalized_specs) def _task_scope(*, task_name: str, run: Callable) -> TaskScope: @@ -399,15 +378,10 @@ def _build_resolved_execution( @lru_cache(maxsize=128) def _resolve_tasks_cached( provider_name: str, - task_specs_key: TaskSpecKey, + task_specs: TaskSpecKey, supported_task_scopes: tuple[TaskScope, ...], ) -> tuple[CachedOrderedTask, CachedAdjacency]: - task_specs: list[dict[str, object]] = [ - {"name": name, "depends_on": list(depends_on), "optional": optional} - for name, depends_on, optional in task_specs_key - ] - - spec_by_name = _parse_task_specs(task_specs) + spec_by_name = {spec.name: spec for spec in task_specs} _validate_dependencies(spec_by_name) @@ -457,36 +431,7 @@ def _resolve_tasks_cached( return ordered, frozen_adjacency -def _parse_task_specs(task_specs: Sequence[TaskSpecInput]) -> dict[str, _TaskSpec]: - spec_by_name: dict[str, _TaskSpec] = {} - - for spec in task_specs: - name = spec["name"] - if not isinstance(name, str): - raise TaskConfigError("task name must be a string") - - if name in spec_by_name: - raise TaskConfigError(f"Duplicate task name detected: '{name}'") - - raw_depends_on = spec.get("depends_on", []) - if not isinstance(raw_depends_on, list): - raise TaskConfigError(f"Task '{name}' depends_on must be a list of strings") - depends_on: list[str] = [] - for dependency in raw_depends_on: - if not isinstance(dependency, str): - raise TaskConfigError( - f"Task '{name}' depends_on must be a list of strings" - ) - depends_on.append(dependency) - - spec_by_name[name] = _TaskSpec( - depends_on=depends_on, optional=bool(spec.get("optional", False)) - ) - - return spec_by_name - - -def _validate_dependencies(spec_by_name: dict[str, _TaskSpec]) -> None: +def _validate_dependencies(spec_by_name: dict[str, TaskSpec]) -> None: names = set(spec_by_name.keys()) for task_name, spec in spec_by_name.items(): @@ -501,7 +446,7 @@ def _validate_dependencies(spec_by_name: dict[str, _TaskSpec]) -> None: def _topological_sort( - spec_by_name: dict[str, _TaskSpec], + spec_by_name: dict[str, TaskSpec], ) -> tuple[list[str], dict[str, list[str]]]: names = list(spec_by_name.keys()) @@ -534,13 +479,13 @@ def _topological_sort( def resolve_tasks( *, task_specs: Sequence[TaskSpecInput], - provider_name: str = "aws", - supported_task_scopes: frozenset[str | TaskScope] = frozenset({"region"}), + provider_name: str, + supported_task_scopes: frozenset[str | TaskScope], ) -> ResolvedExecution: - task_specs_key = _freeze_task_specs(task_specs) + normalized_specs = _normalize_task_specs(task_specs) normalized_scopes = _normalize_supported_task_scopes(supported_task_scopes) ordered, adjacency = _resolve_tasks_cached( - provider_name, task_specs_key, normalized_scopes + provider_name, normalized_specs, normalized_scopes ) return _build_resolved_execution(ordered, adjacency) @@ -549,32 +494,23 @@ def discover_tasks() -> TaskDiscoveryResult: """Discover built-in and plugin provider-aware tasks.""" from anvil.provider_loader import list_providers - tasks: list[TaskDescriptor] = [] - task_keys: set[tuple[str, str, int]] = set() - issue_keys: set[tuple[str, ComponentSource, str]] = set() - issues: list[DiscoveryIssue] = [] + tasks: set[TaskDescriptor] = set() + issues: set[DiscoveryIssue] = set() for provider_name in sorted({provider.name for provider in list_providers()}): index, provider_issues = _provider_task_discovery(provider_name) - for name, descriptors in index.items(): - source_counts: dict[tuple[str, str], int] = defaultdict(int) - for descriptor in descriptors: - source_key = (descriptor.source, name) - ordinal = source_counts[source_key] - source_counts[source_key] += 1 - task_key = (descriptor.source, name, ordinal) - if task_key in task_keys: - continue - task_keys.add(task_key) - tasks.append(descriptor) - for issue in provider_issues: - key = (issue.name, issue.source, issue.error) - if key in issue_keys: - continue - issue_keys.add(key) - issues.append(issue) - - return TaskDiscoveryResult(tasks=tasks, issues=issues) + for descriptors in index.values(): + tasks.update(descriptors) + issues.update(provider_issues) + + return TaskDiscoveryResult( + tasks=sorted(tasks, key=lambda task: (str(task.source), task.name)), + issues=sorted( + issues, key=lambda issue: (str(issue.source), issue.name, issue.error) + ), + ) def list_tasks() -> list[TaskDescriptor]: - return sorted(discover_tasks().tasks, key=lambda task: (task.source, task.name)) + return sorted( + discover_tasks().tasks, key=lambda task: (str(task.source), task.name) + ) diff --git a/src/anvil/validators.py b/src/anvil/validators.py index 75103a2..48769c4 100644 --- a/src/anvil/validators.py +++ b/src/anvil/validators.py @@ -177,6 +177,8 @@ def validate_target_descriptors(*, targets: list[TargetDescriptor]) -> None: """ Validate semantic correctness across loaded target descriptors. """ + from anvil.provider_loader import load_provider + seen_names: set[str] = set() for target in targets: @@ -184,6 +186,8 @@ def validate_target_descriptors(*, targets: list[TargetDescriptor]) -> None: raise ValueError(f"Duplicate target name detected: '{target.name}'") seen_names.add(target.name) + provider = load_provider(target.provider) + provider.validate_target(target) combined_concurrency = target.max_workers * target.max_parallel_regions if target.fail_fast and combined_concurrency > 10: diff --git a/tests/auth/test_auth sources.py b/tests/auth/test_auth sources.py index c6477b1..0659998 100644 --- a/tests/auth/test_auth sources.py +++ b/tests/auth/test_auth sources.py @@ -1,4 +1,4 @@ -from anvil.auth import AuthSource, infer_auth_source +from anvil.providers.aws.auth import AuthSource, infer_auth_source def test_infer_auth_source_environment(monkeypatch): diff --git a/tests/auth/test_auth_check.py b/tests/auth/test_auth_check.py index b47a3c0..2ad2585 100644 --- a/tests/auth/test_auth_check.py +++ b/tests/auth/test_auth_check.py @@ -1,6 +1,6 @@ from botocore.exceptions import TokenRetrievalError -from anvil.auth import AuthSource, auth_check +from anvil.providers.aws.auth import AuthSource, auth_check from anvil.results import ExecutionStatus diff --git a/tests/cli/test_cli_parallel_targets.py b/tests/cli/test_cli_parallel_targets.py index 6bd8d90..1ab3275 100644 --- a/tests/cli/test_cli_parallel_targets.py +++ b/tests/cli/test_cli_parallel_targets.py @@ -31,7 +31,12 @@ def test_run_single_config_file_passes_run_controls( cli = _import_cli_or_skip() - target = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="target-a") + target = TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name="target-a", + provider="aws", + mode="organization", + ) loaded_config = SimpleNamespace( branch=ConfigBranch.TARGETS, targets=[target], max_parallel_targets=4 ) @@ -86,7 +91,7 @@ def test_validate_cli_overrides_rejects_explicit_mode_exclude(): ], ) - with pytest.raises(ValueError, match="explicit provider modes.*aws-accounts"): + with pytest.raises(ValueError, match="AWS mode 'accounts'.*--exclude"): cli._validate_cli_overrides( loaded_config=loaded_config, args=SimpleNamespace(exclude=["111111111111"]) ) diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py index 241e21e..52ea681 100644 --- a/tests/cli/test_cli_smoke.py +++ b/tests/cli/test_cli_smoke.py @@ -247,6 +247,7 @@ def test_build_rerun_targets_narrows_entities_regions_and_task_dependencies(): TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", regions=["us-east-1", "us-west-2"], include=["111111111111", "222222222222"], @@ -259,6 +260,7 @@ def test_build_rerun_targets_narrows_entities_regions_and_task_dependencies(): TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-b", + provider="aws", mode="organization", regions=["us-east-1"], tasks=[{"name": "inventory"}], @@ -504,6 +506,8 @@ def test_run_configured_post_processors_runs_successful_targets(monkeypatch): target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", + mode="organization", metadata={"team": "security"}, post_run=[ { @@ -514,7 +518,10 @@ def test_run_configured_post_processors_runs_successful_targets(monkeypatch): ], ) loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) - target_result = SimpleNamespace(target_name="org-a", has_failures=False) + target_result_payload = {"target": "org-a", "provider": "aws"} + target_result = SimpleNamespace( + target_name="org-a", has_failures=False, to_dict=lambda: target_result_payload + ) engine_result = SimpleNamespace(target_results=[target_result]) written_results = SimpleNamespace( run_dir=Path("results/orgs/run"), @@ -545,7 +552,7 @@ def fake_run_processors(*, specs, context): ) assert seen["specs"][0].metadata == {"include_passed": False} assert seen["context"].target_name == "org-a" - assert seen["context"].target_result is target_result + assert seen["context"].target_result == target_result_payload assert seen["context"].target_metadata == {"team": "security"} @@ -559,6 +566,8 @@ def test_run_configured_post_processors_skips_failed_targets(monkeypatch): target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", + mode="organization", post_run=[{"processor": "summary_markdown"}], ) loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) @@ -599,6 +608,8 @@ def test_run_configured_post_processors_runs_failure_opt_in(monkeypatch): target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", + mode="organization", post_run=[ {"processor": "success_only"}, { @@ -609,7 +620,10 @@ def test_run_configured_post_processors_runs_failure_opt_in(monkeypatch): ], ) loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) - target_result = SimpleNamespace(target_name="org-a", has_failures=True) + target_result_payload = {"target": "org-a", "provider": "aws"} + target_result = SimpleNamespace( + target_name="org-a", has_failures=True, to_dict=lambda: target_result_payload + ) written_results = SimpleNamespace( run_dir=Path("results/orgs/run"), summary_path=Path("results/orgs/run/summary.json"), @@ -638,7 +652,7 @@ def fake_run_processors(*, specs, context): Path("results/orgs/run/reports/org-a-status.html") ) assert seen["specs"][0].run_on_failure is True - assert seen["context"].target_result is target_result + assert seen["context"].target_result == target_result_payload def test_cmd_results_processor_runs_completed_results_context(monkeypatch): diff --git a/tests/cli/test_list_command.py b/tests/cli/test_list_command.py index 9ea3156..8665059 100644 --- a/tests/cli/test_list_command.py +++ b/tests/cli/test_list_command.py @@ -229,19 +229,30 @@ def test_cmd_list_processors_groups_by_source(monkeypatch, capsys): def test_cmd_list_providers_groups_by_source(monkeypatch, capsys): + from anvil._components import ComponentOrigin, ComponentSource + cli = _import_cli_or_skip() monkeypatch.setattr( cli, "list_providers", lambda: [ cli.ProviderDescriptor( - name="aws", display_name="AWS", load=lambda: None, source="stock" + name="aws", + load=lambda: None, + source=ComponentSource( + origin=ComponentOrigin.STOCK, + package="anvil.providers", + label="stock", + ), ), cli.ProviderDescriptor( name="custom", - display_name="Custom", load=lambda: None, - source="plugin: my-plugin", + source=ComponentSource( + origin=ComponentOrigin.PLUGIN, + package="custom.providers", + label="plugin: my-plugin", + ), ), ], ) diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index a428b6e..6785fef 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -5,9 +5,22 @@ import pytest +from anvil._components import ComponentOrigin, ComponentSource from anvil.providers.base import ProviderMetadata +def _source(label: str) -> ComponentSource: + return ComponentSource( + origin=( + ComponentOrigin.PLUGIN + if label.startswith("plugin:") + else ComponentOrigin.STOCK + ), + package="tests.providers", + label=label, + ) + + def _import_cli_or_skip(): try: from anvil import cli @@ -483,7 +496,9 @@ def test_validate_selected_providers_validates_all_when_no_names(monkeypatch): seen = {} class Provider: - metadata = ProviderMetadata(name="aws", display_name="AWS") + metadata = ProviderMetadata( + name="aws", display_name="AWS", supported_task_scopes=frozenset({"region"}) + ) def validate_target(self, target): return None @@ -497,9 +512,17 @@ def auth_cache_key(self, target): def auth_check(self, target): return None + def resolve_target_filters(self, *, target, include_override, exclude_override): + return target.include, target.exclude + def discover_regions(self, target): return [] + def prepare_target( + self, *, target, context, include, exclude, cache, benchmark + ): + return None + def resolve_execution_targets(self, *, target, regions, include, exclude): return None @@ -516,7 +539,7 @@ def load_provider(): lambda: SimpleNamespace( providers=[ cli.ProviderDescriptor( - name="aws", display_name="AWS", load=load_provider, source="stock" + name="aws", load=load_provider, source=_source("stock") ) ], issues=[], @@ -557,7 +580,7 @@ def test_validate_selected_providers_reports_unknown_names(monkeypatch): lambda: SimpleNamespace( providers=[ cli.ProviderDescriptor( - name="aws", display_name="AWS", load=lambda: None, source="stock" + name="aws", load=lambda: None, source=_source("stock") ) ], issues=[], @@ -572,7 +595,9 @@ def test_validate_selected_providers_reports_contract_member(monkeypatch): cli = _import_cli_or_skip() class BrokenProvider: - metadata = ProviderMetadata(name="broken", display_name="Broken") + metadata = ProviderMetadata( + name="broken", display_name="Broken", supported_task_scopes=frozenset() + ) def validate_target(self, target): return None @@ -584,9 +609,8 @@ def validate_target(self, target): providers=[ cli.ProviderDescriptor( name="broken", - display_name="Broken", load=lambda: BrokenProvider(), - source="plugin: broken-provider", + source=_source("plugin: broken-provider"), ) ], issues=[], @@ -598,7 +622,7 @@ def validate_target(self, target): error = str(exc_info.value) assert "broken (plugin: broken-provider)" in error - assert "auth_cache_key" in error + assert "resolve_target_filters" in error def test_validate_aggregates_failures_and_successes(monkeypatch, capsys): diff --git a/tests/providers/aws/test_execution_targets.py b/tests/providers/aws/test_execution_targets.py index 9371f79..0d9877f 100644 --- a/tests/providers/aws/test_execution_targets.py +++ b/tests/providers/aws/test_execution_targets.py @@ -59,9 +59,9 @@ def test_resolve_execution_targets_maps_explicit_assume_role_accounts(monkeypatc target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="selected", + provider="aws", mode="accounts", - profile="tooling", - role_name="SecurityAccessRole", + provider_options={"profile": "tooling", "role_name": "SecurityAccessRole"}, include=["111111111111", "222222222222"], ) @@ -72,7 +72,6 @@ def test_resolve_execution_targets_maps_explicit_assume_role_accounts(monkeypatc exclude=target.exclude, ) - assert plan.exclusive_execution_key is None assert [execution_target.id for execution_target in plan.execution_targets] == [ "111111111111", "222222222222", @@ -98,8 +97,9 @@ def test_resolve_execution_targets_maps_explicit_direct_profile_account(monkeypa target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="current", + provider="aws", mode="accounts", - profile="dev-admin", + provider_options={"profile": "dev-admin"}, include=["111111111111"], ) @@ -139,7 +139,6 @@ def test_resolve_execution_targets_maps_explicit_assume_role_accounts_with_provi exclude=target.exclude, ) - assert plan.exclusive_execution_key is None assert [execution_target.id for execution_target in plan.execution_targets] == [ "111111111111", "222222222222", @@ -155,8 +154,9 @@ def test_resolve_execution_targets_maps_organization_accounts_and_execution_key( target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", - profile="shared", + provider_options={"profile": "shared"}, include=["222222222222"], ) @@ -165,12 +165,11 @@ def test_resolve_execution_targets_maps_organization_accounts_and_execution_key( regions=["us-east-1"], include=target.include, exclude=target.exclude, - preflight_data=_preflight_data( + preparation=_preflight_data( session_factory=session_factory, base_session=base_session ), ) - assert plan.exclusive_execution_key == "o-shared" assert [execution_target.id for execution_target in plan.execution_targets] == [ "222222222222" ] @@ -201,12 +200,11 @@ def test_resolve_execution_targets_maps_organization_accounts_with_provider_opti regions=["us-east-1"], include=target.include, exclude=target.exclude, - preflight_data=_preflight_data( + preparation=_preflight_data( session_factory=session_factory, base_session=base_session ), ) - assert plan.exclusive_execution_key == "o-shared" assert [execution_target.id for execution_target in plan.execution_targets] == [ "222222222222" ] @@ -217,6 +215,7 @@ def test_resolve_execution_targets_preserves_unknown_include_warning(caplog): target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", include=["999999999999"], ) @@ -226,7 +225,7 @@ def test_resolve_execution_targets_preserves_unknown_include_warning(caplog): regions=["us-east-1"], include=target.include, exclude=target.exclude, - preflight_data=_preflight_data( + preparation=_preflight_data( session_factory=FakeSessionFactory(), discovered_accounts={ "111111111111": { @@ -245,6 +244,7 @@ def test_resolve_execution_targets_preserves_unknown_exclude_warning(caplog): target = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", exclude=["999999999999"], ) @@ -254,7 +254,7 @@ def test_resolve_execution_targets_preserves_unknown_exclude_warning(caplog): regions=["us-east-1"], include=target.include, exclude=target.exclude, - preflight_data=_preflight_data( + preparation=_preflight_data( session_factory=FakeSessionFactory(), discovered_accounts={ "111111111111": { diff --git a/tests/providers/aws/test_regions.py b/tests/providers/aws/test_regions.py index 1f0c5c4..f5a0ec6 100644 --- a/tests/providers/aws/test_regions.py +++ b/tests/providers/aws/test_regions.py @@ -50,7 +50,12 @@ def client(self, service_name, **kwargs): def test_aws_provider_metadata_declares_default_region(): - target = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="org-a") + target = TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name="org-a", + provider="aws", + mode="organization", + ) assert target.regions is None assert AwsProvider.metadata.default_regions == ("us-east-1",) @@ -95,7 +100,11 @@ def create_base_session(self, **kwargs): ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", regions=["all"] + config_branch=ConfigBranch.TARGETS, + name="org-a", + provider="aws", + mode="organization", + regions=["all"], ) regions = AwsProvider().discover_regions(target) diff --git a/tests/providers/aws/test_runtime.py b/tests/providers/aws/test_runtime.py index 242f088..2670e6a 100644 --- a/tests/providers/aws/test_runtime.py +++ b/tests/providers/aws/test_runtime.py @@ -6,7 +6,7 @@ import pytest -from anvil.account import ( +from anvil.providers.aws.account import ( MINIMUM_ASSUMED_CREDENTIAL_REFRESH_WINDOW, AccountAccessStrategy, ) @@ -16,7 +16,7 @@ from anvil.providers.base import ExecutionTarget from anvil.results import ExecutionStatus from anvil.runner import _execute_provider_execution_target -from anvil.session import AssumedRoleCredentials, CachedClientSession +from anvil.providers.aws.session import AssumedRoleCredentials, CachedClientSession from anvil.task_loader import ResolvedTask @@ -94,8 +94,10 @@ def _target() -> TargetDescriptor: return TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="selected", + provider="aws", + mode="accounts", include=["123456789012"], - role_name="TestRole", + provider_options={"role_name": "TestRole"}, ) @@ -107,7 +109,6 @@ def _context( ) -> ExecutionContext: return ExecutionContext( regions=regions or ["us-east-1"], - role_name="TestRole", dry_run=True, tasks=tasks or [], metadata={}, @@ -126,11 +127,17 @@ def _execution_target( name="test-account", type="account", provider="aws", + regions=regions or ["us-east-1"], provider_data=AwsExecutionTargetData( account_id="123456789012", account_alias="test-account", is_management=False, access_strategy=access_strategy, + role_name=( + "TestRole" + if access_strategy is AccountAccessStrategy.ASSUME_ROLE + else None + ), base_session=BaseSession(), regions=regions or ["us-east-1"], session_factory=session_factory, diff --git a/tests/providers/azure/test_azure_provider.py b/tests/providers/azure/test_azure_provider.py index c726f99..01c4589 100644 --- a/tests/providers/azure/test_azure_provider.py +++ b/tests/providers/azure/test_azure_provider.py @@ -117,8 +117,8 @@ def _target(**overrides) -> TargetDescriptor: values = { "config_branch": ConfigBranch.TARGETS, "name": "azure-subscriptions", - "provider": "azure", "mode": "subscriptions", + "provider": "azure", "include": ["sub-a"], } values.update(overrides) @@ -130,6 +130,8 @@ def _target(**overrides) -> TargetDescriptor: def _raw_target(**overrides): values = { "config_branch": ConfigBranch.TARGETS, + "name": "azure-subscriptions", + "mode": "subscriptions", "include": ["sub-a"], "exclude": None, "provider": "azure", @@ -140,9 +142,7 @@ def _raw_target(**overrides): def _context() -> ExecutionContext: - return ExecutionContext( - regions=["eastus"], role_name=None, dry_run=False, tasks=[], metadata={} - ) + return ExecutionContext(regions=["eastus"], dry_run=False, tasks=[], metadata={}) def test_azure_provider_metadata_and_default_locations(): @@ -158,15 +158,20 @@ def test_azure_provider_metadata_and_default_locations(): def test_azure_provider_rejects_organization_targets(): provider = AzureProvider() - target = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="mgmt") + target = TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name="mgmt", + provider="azure", + mode="organization", + ) - with pytest.raises(ValueError, match="provider 'azure'"): + with pytest.raises(ValueError, match="Unsupported Azure target mode"): provider.validate_target(target) def test_azure_provider_rejects_tenant_id_without_client_secret(): provider = AzureProvider() - target = _raw_target(provider_options={"tenant_id": "tenant-a"}) + target = _target(provider_options={"tenant_id": "tenant-a"}) with pytest.raises(ValueError, match="tenant_id.*client_secret"): provider.validate_target(target) @@ -174,7 +179,7 @@ def test_azure_provider_rejects_tenant_id_without_client_secret(): def test_azure_provider_rejects_client_secret_without_tenant_and_client_id(): provider = AzureProvider() - target = _raw_target(provider_options={"client_secret": "secret-a"}) + target = _target(provider_options={"client_secret": "secret-a"}) with pytest.raises(ValueError, match="client_secret.*tenant_id.*client_id"): provider.validate_target(target) @@ -197,7 +202,6 @@ def test_azure_resolves_explicit_subscription_targets_deterministically(): exclude=None, ) - assert plan.exclusive_execution_key is None assert [execution_target.id for execution_target in plan.execution_targets] == [ "sub-a", "sub-b", @@ -224,11 +228,16 @@ def test_azure_subscription_targets_exclude_each_selected_subscription(): provider = AzureProvider(session_factory=FakeSessionFactory()) target = _target(include=["sub-a", "sub-b"]) - keys = provider.execution_exclusion_keys( - target=target, include=["sub-b", "sub-c"], exclude=None + result = provider.prepare_target( + target=target, + context=_context(), + include=["sub-b", "sub-c"], + exclude=None, + cache=SimpleNamespace(), + benchmark=None, ) - assert keys == ( + assert result.exclusive_execution_keys == ( ("azure", "subscription", "sub-b"), ("azure", "subscription", "sub-c"), ) @@ -246,8 +255,13 @@ def test_azure_tenant_preflight_discovers_selected_subscriptions_for_exclusion() }, ) - result = provider.preflight_execution( - target=target, regions=["eastus"], include=target.include, exclude=None + result = provider.prepare_target( + target=target, + context=_context(), + include=target.include, + exclude=None, + cache=SimpleNamespace(), + benchmark=None, ) assert result.data is not None @@ -261,8 +275,13 @@ def test_azure_tenant_preflight_without_tenant_id_uses_default_credential_discov provider = AzureProvider(session_factory=FakeSessionFactory()) target = _target(mode="tenant", include=["sub-a"]) - result = provider.preflight_execution( - target=target, regions=["eastus"], include=target.include, exclude=None + result = provider.prepare_target( + target=target, + context=_context(), + include=target.include, + exclude=None, + cache=SimpleNamespace(), + benchmark=None, ) assert result.data is not None @@ -283,11 +302,14 @@ def test_azure_preflight_records_location_validation_benchmark_data(): target = _target(mode="tenant", include=["sub-a"]) benchmark: dict[str, object] = {} - result = provider.preflight_execution( + result = provider.prepare_target( target=target, - regions=["eastus", "westus2"], + context=ExecutionContext( + regions=["eastus", "westus2"], dry_run=False, tasks=[], metadata={} + ), include=target.include, exclude=None, + cache=SimpleNamespace(), benchmark=benchmark, ) @@ -423,8 +445,13 @@ def test_azure_resolve_execution_targets_reuses_preflight_subscriptions(): ) provider = AzureProvider(session_factory=session_factory) target = _target(include=None) - preflight = provider.preflight_execution( - target=target, regions=["eastus"], include=["sub-b"], exclude=None + preflight = provider.prepare_target( + target=target, + context=_context(), + include=["sub-b"], + exclude=None, + cache=SimpleNamespace(), + benchmark=None, ) plan = provider.resolve_execution_targets( @@ -432,7 +459,7 @@ def test_azure_resolve_execution_targets_reuses_preflight_subscriptions(): regions=["eastus"], include=["sub-b"], exclude=None, - preflight_data=preflight.data, + preparation=preflight.data, ) assert [execution_target.id for execution_target in plan.execution_targets] == [ diff --git a/tests/providers/gcp/test_gcp_provider.py b/tests/providers/gcp/test_gcp_provider.py index 66a3c9d..a4e1c81 100644 --- a/tests/providers/gcp/test_gcp_provider.py +++ b/tests/providers/gcp/test_gcp_provider.py @@ -100,7 +100,7 @@ def _target(**overrides) -> TargetDescriptor: def _context() -> ExecutionContext: return ExecutionContext( - regions=["us-central1"], role_name=None, dry_run=False, tasks=[], metadata={} + regions=["us-central1"], dry_run=False, tasks=[], metadata={} ) @@ -117,9 +117,14 @@ def test_gcp_provider_metadata_and_default_locations(): def test_gcp_provider_rejects_organization_targets(): provider = GcpProvider() - target = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="folder") + target = TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name="folder", + provider="gcp", + mode="folders", + ) - with pytest.raises(ValueError, match="provider 'gcp'"): + with pytest.raises(ValueError, match="Unsupported GCP target mode"): provider.validate_target(target) @@ -135,7 +140,6 @@ def test_gcp_resolves_explicit_project_targets_deterministically(): exclude=None, ) - assert plan.exclusive_execution_key is None assert [execution_target.id for execution_target in plan.execution_targets] == [ "project-a", "project-b", diff --git a/tests/providers/github/test_github_provider.py b/tests/providers/github/test_github_provider.py index 0fb62c0..f174159 100644 --- a/tests/providers/github/test_github_provider.py +++ b/tests/providers/github/test_github_provider.py @@ -235,9 +235,7 @@ def _target(**overrides) -> TargetDescriptor: def _context() -> ExecutionContext: - return ExecutionContext( - regions=["global"], role_name=None, dry_run=False, tasks=[], metadata={} - ) + return ExecutionContext(regions=["global"], dry_run=False, tasks=[], metadata={}) def _install_fake_pygithub(monkeypatch) -> ModuleType: @@ -265,8 +263,11 @@ def test_github_provider_metadata_and_default_location(): def test_github_provider_requires_explicit_include(): + provider = create_provider_instance() + target = _target(include=None) + with pytest.raises(ValueError, match="requires include"): - _target(include=None) + provider.validate_target(target) def test_github_provider_rejects_exclude(): @@ -275,18 +276,27 @@ def test_github_provider_rejects_exclude(): def test_github_provider_rejects_removed_auth_type(): + provider = create_provider_instance() + target = _target(provider_options={"auth_type": "token"}) + with pytest.raises(ValueError, match="auth_type"): - _target(provider_options={"auth_type": "token"}) + provider.validate_target(target) def test_github_provider_rejects_removed_installation_id(): + provider = create_provider_instance() + target = _target(provider_options={"installation_id": "67890"}) + with pytest.raises(ValueError, match="installation_id"): - _target(provider_options={"installation_id": "67890"}) + provider.validate_target(target) def test_github_provider_rejects_profile_with_inline_auth(): + provider = create_provider_instance() + target = _target(provider_options={"profile": "work", "token_env": "GITHUB_TOKEN"}) + with pytest.raises(ValueError, match="profile cannot be combined"): - _target(provider_options={"profile": "work", "token_env": "GITHUB_TOKEN"}) + provider.validate_target(target) def test_github_provider_rejects_repository_include_without_owner(): @@ -444,6 +454,7 @@ def test_github_prepare_runtime_rejects_wrong_provider(): name="octo-org/example", type="repository", provider="aws", + regions=["global"], provider_data=GithubExecutionTargetData( target_id="octo-org/example", target_type="repository", @@ -466,6 +477,7 @@ def test_github_prepare_runtime_rejects_wrong_provider_data_type(): name="octo-org/example", type="repository", provider="github", + regions=["global"], provider_data=object(), ) diff --git a/tests/providers/test_provider_contract.py b/tests/providers/test_provider_contract.py index 6e0a6fc..fe2579a 100644 --- a/tests/providers/test_provider_contract.py +++ b/tests/providers/test_provider_contract.py @@ -18,6 +18,38 @@ ) +class _CompleteProvider: + metadata = ProviderMetadata( + name="complete", display_name="Complete", supported_task_scopes=frozenset() + ) + + def validate_target(self, target): + return None + + def resolve_target_filters(self, *, target, include_override, exclude_override): + return target.include, target.exclude + + def auth_cache_key(self, target): + return None + + def auth_check(self, target): + return None + + def discover_regions(self, target): + return [] + + def prepare_target(self, *, target, context, include, exclude, cache, benchmark): + return None + + def resolve_execution_targets( + self, *, target, regions, include, exclude, preparation=None + ): + return None + + def prepare_execution_runtime(self, *, target, execution_target, context): + return None + + def test_first_party_providers_satisfy_provider_contract(): providers = [ create_aws_provider_instance(), @@ -65,87 +97,33 @@ def test_validate_resolved_regions_rejects_invalid_values(regions): def test_provider_contract_rejects_empty_metadata_name(): - class BrokenProvider: - metadata = ProviderMetadata(name="", display_name="Broken") - - def validate_target(self, target): - return None - - def default_regions(self, target): - return [] - - def auth_cache_key(self, target): - return None - - def auth_check(self, target): - return None - - def discover_regions(self, target): - return [] - - def resolve_execution_targets(self, *, target, regions, include, exclude): - return None - - def prepare_execution_runtime(self, *, target, execution_target, context): - return None + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="", display_name="Broken", supported_task_scopes=frozenset() + ) with pytest.raises(ValueError, match="metadata name"): validate_provider_contract(BrokenProvider()) def test_provider_contract_rejects_empty_display_name(): - class BrokenProvider: - metadata = ProviderMetadata(name="broken", display_name="") - - def validate_target(self, target): - return None - - def default_regions(self, target): - return [] - - def auth_cache_key(self, target): - return None - - def auth_check(self, target): - return None - - def discover_regions(self, target): - return [] - - def resolve_execution_targets(self, *, target, regions, include, exclude): - return None - - def prepare_execution_runtime(self, *, target, execution_target, context): - return None + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="broken", display_name="", supported_task_scopes=frozenset() + ) with pytest.raises(ValueError, match="display_name"): validate_provider_contract(BrokenProvider()) def test_provider_contract_rejects_missing_contract_parameter(): - class BrokenProvider: - metadata = ProviderMetadata(name="broken", display_name="Broken") - - def validate_target(self, target): - return None - - def default_regions(self, target): - return [] - - def auth_cache_key(self, target): - return None - - def auth_check(self, target): - return None - - def discover_regions(self, target): - return [] + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="broken", display_name="Broken", supported_task_scopes=frozenset() + ) def resolve_execution_targets(self, *, target, regions, include): return None - def prepare_execution_runtime(self, *, target, execution_target, context): - return None - with pytest.raises(TypeError, match="resolve_execution_targets.*exclude"): validate_provider_contract(BrokenProvider()) diff --git a/tests/providers/test_provider_loader.py b/tests/providers/test_provider_loader.py index 8033bd9..2d60dbd 100644 --- a/tests/providers/test_provider_loader.py +++ b/tests/providers/test_provider_loader.py @@ -14,14 +14,11 @@ def test_list_providers_returns_aws_without_loading_provider(monkeypatch): providers = provider_loader.list_providers() - assert [ - (provider.name, provider.display_name, provider.source) - for provider in providers - ] == [ - ("aws", "AWS", "stock"), - ("azure", "Azure", "stock"), - ("gcp", "GCP", "stock"), - ("github", "GitHub", "stock"), + assert [(provider.name, str(provider.source)) for provider in providers] == [ + ("aws", "stock"), + ("azure", "stock"), + ("gcp", "stock"), + ("github", "stock"), ] @@ -66,7 +63,20 @@ def test_provider_package_entry_point_discovers_child_folders( ( "from anvil.providers.base import ProviderMetadata\n" "class CustomProvider:\n" - " metadata = ProviderMetadata(name='custom', display_name='Custom')\n" + " metadata = ProviderMetadata(name='custom', display_name='Custom', " + "supported_task_scopes=frozenset())\n" + " def validate_target(self, target): pass\n" + " def resolve_target_filters(self, *, target, include_override, " + "exclude_override): return target.include, target.exclude\n" + " def auth_cache_key(self, target): return None\n" + " def auth_check(self, target): pass\n" + " def discover_regions(self, target): return []\n" + " def prepare_target(self, *, target, context, include, exclude, cache, " + "benchmark): pass\n" + " def resolve_execution_targets(self, *, target, regions, include, " + "exclude, preparation=None): pass\n" + " def prepare_execution_runtime(self, *, target, execution_target, " + "context): pass\n" "def create_provider_instance():\n" " return CustomProvider()\n" ), @@ -100,7 +110,7 @@ class EntryPoint: if item.name == "custom" ) - assert descriptor.source == "plugin: company-anvil" + assert str(descriptor.source) == "plugin: company-anvil" assert "company_providers.custom" not in sys.modules first_provider = provider_loader.load_provider("custom") diff --git a/tests/providers/test_provider_owned_target_validation.py b/tests/providers/test_provider_owned_target_validation.py new file mode 100644 index 0000000..acedc25 --- /dev/null +++ b/tests/providers/test_provider_owned_target_validation.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import pytest + +from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.providers.aws.provider import AwsProvider +from anvil.providers.azure.provider import AzureProvider + + +def _target( + *, + provider: str, + mode: str, + include: list[str] | None = None, + exclude: list[str] | None = None, + provider_options: dict[str, object] | None = None, +) -> TargetDescriptor: + return TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name=f"{provider}-{mode}", + provider=provider, + mode=mode, + include=include, + exclude=exclude, + provider_options=provider_options or {}, + ) + + +def test_shared_descriptor_accepts_plugin_owned_modes_and_option_shapes() -> None: + target = _target( + provider="Acme", + mode="Fleet", + provider_options={"nested": {"enabled": True}, "batch_size": 25}, + ) + + assert target.provider == "acme" + assert target.mode == "fleet" + assert target.provider_options == {"nested": {"enabled": True}, "batch_size": 25} + + +def test_first_party_provider_rejects_an_unknown_mode() -> None: + target = _target(provider="azure", mode="projects") + + with pytest.raises(ValueError, match="Unsupported Azure target mode"): + AzureProvider().validate_target(target) + + +def test_aws_explicit_accounts_without_role_are_direct_and_single_account() -> None: + target = _target( + provider="aws", mode="accounts", include=["111111111111", "222222222222"] + ) + + with pytest.raises(ValueError, match="without role_name.*exactly one"): + AwsProvider().validate_target(target) + + +def test_aws_explicit_accounts_do_not_receive_the_organization_default_role() -> None: + target = _target(provider="aws", mode="accounts", include=["111111111111"]) + + AwsProvider().validate_target(target) + assert target.provider_options.get("role_name") is None + + +def test_provider_owns_cli_filter_semantics() -> None: + target = _target( + provider="azure", + mode="subscriptions", + include=["subscription-a", "subscription-b"], + ) + + assert AzureProvider().resolve_target_filters( + target=target, + include_override=["subscription-b", "not-configured"], + exclude_override=None, + ) == (["subscription-b"], None) + + with pytest.raises(ValueError, match="does not allow --exclude"): + AzureProvider().resolve_target_filters( + target=target, include_override=None, exclude_override=["subscription-a"] + ) diff --git a/tests/results/test_result_query.py b/tests/results/test_result_query.py index 38eac2e..1355860 100644 --- a/tests/results/test_result_query.py +++ b/tests/results/test_result_query.py @@ -41,12 +41,15 @@ def _target_result() -> TargetResult: return TargetResult.create( config_branch=ConfigBranch.TARGETS, target_name="engineering", + provider="aws", dry_run=True, entities=[ EntityResult( id="111111111111", name="dev", type="account", + provider="aws", + metadata={}, status=ExecutionStatus.ERROR, started_at="2026-04-30T00:00:00+00:00", ended_at="2026-04-30T00:00:02+00:00", @@ -191,6 +194,7 @@ def test_build_rerun_targets_includes_interrupted_task_dependencies(): TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", regions=["us-east-1", "us-west-2"], tasks=[ diff --git a/tests/results/test_results.py b/tests/results/test_results.py index 1487f16..0952e1c 100644 --- a/tests/results/test_results.py +++ b/tests/results/test_results.py @@ -13,6 +13,8 @@ def _entity_result(*, entity_id: str, status: ExecutionStatus) -> EntityResult: id=entity_id, name=f"acct-{entity_id}", type="account", + provider="aws", + metadata={}, status=status, started_at="2026-03-25T00:00:00+00:00", ended_at="2026-03-25T00:00:01+00:00", @@ -25,6 +27,7 @@ def test_engine_summary_counts_interrupted_entities() -> None: target_result = TargetResult.create( config_branch=ConfigBranch.TARGETS, target_name="org-a", + provider="aws", dry_run=True, entities=[ _entity_result(entity_id="111111111111", status=ExecutionStatus.SUCCESS), diff --git a/tests/runner/test_account_resolver.py b/tests/runner/test_account_resolver.py index c11ed99..45f6dc7 100644 --- a/tests/runner/test_account_resolver.py +++ b/tests/runner/test_account_resolver.py @@ -1,7 +1,7 @@ from __future__ import annotations -from anvil.account import AccountAccessStrategy -from anvil.account_resolver import AccountResolver +from anvil.providers.aws.account import AccountAccessStrategy +from anvil.providers.aws.account_resolver import AccountResolver from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext @@ -11,21 +11,20 @@ def create_base_session(self, **kwargs): return type("_BaseSession", (), {"profile_name": kwargs["profile_name"]})() -def _context(*, role_name: str | None = None) -> ExecutionContext: - return ExecutionContext( - regions=["us-east-1"], role_name=role_name, dry_run=True, tasks=[], metadata={} - ) +def _context() -> ExecutionContext: + return ExecutionContext(regions=["us-east-1"], dry_run=True, tasks=[], metadata={}) def test_resolve_accounts_uses_assume_role_strategy_when_role_name_is_configured(): descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="selected", - profile="tooling", - role_name="SecurityAccessRole", + provider="aws", + mode="accounts", + provider_options={"profile": "tooling", "role_name": "SecurityAccessRole"}, include=["111111111111", "222222222222"], ) - context = _context(role_name="SecurityAccessRole") + context = _context() accounts = AccountResolver( descriptor=descriptor, context=context, session_factory=FakeSessionFactory() @@ -39,13 +38,19 @@ def test_resolve_accounts_uses_assume_role_strategy_when_role_name_is_configured AccountAccessStrategy.ASSUME_ROLE, AccountAccessStrategy.ASSUME_ROLE, ] + assert [account.role_name for account in accounts] == [ + "SecurityAccessRole", + "SecurityAccessRole", + ] def test_resolve_accounts_uses_direct_profile_strategy_without_role_name(): descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="current", - profile="dev-admin", + provider="aws", + mode="accounts", + provider_options={"profile": "dev-admin"}, include=["111111111111"], ) context = _context() @@ -56,3 +61,4 @@ def test_resolve_accounts_uses_direct_profile_strategy_without_role_name(): assert [account.account_id for account in accounts] == ["111111111111"] assert accounts[0].access_strategy is AccountAccessStrategy.DIRECT_PROFILE + assert accounts[0].role_name is None diff --git a/tests/runner/test_organization_resolver.py b/tests/runner/test_organization_resolver.py index 7dbe41b..a9135ea 100644 --- a/tests/runner/test_organization_resolver.py +++ b/tests/runner/test_organization_resolver.py @@ -4,8 +4,9 @@ from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext -from anvil.account import AccountAccessStrategy -from anvil.organization import OrganizationResolver +from anvil.providers.aws.account import AccountAccessStrategy +from anvil.providers.aws.config import DEFAULT_ORGANIZATION_ROLE_NAME +from anvil.providers.aws.organization import OrganizationResolver from anvil.providers.aws.regions import AwsRegionService @@ -40,11 +41,7 @@ def create_base_session(self, **kwargs): def _context(*, regions: list[str] | None = None) -> ExecutionContext: return ExecutionContext( - regions=regions or ["us-east-1"], - role_name="TestRole", - dry_run=True, - tasks=[], - metadata={}, + regions=regions or ["us-east-1"], dry_run=True, tasks=[], metadata={} ) @@ -52,8 +49,9 @@ def _target(**kwargs) -> TargetDescriptor: return TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="org-a", + provider="aws", mode="organization", - profile="profile-a", + provider_options={"profile": "profile-a"}, regions=kwargs.pop("regions", ["us-east-1"]), **kwargs, ) @@ -188,6 +186,7 @@ def test_resolve_accounts_uses_default_management_account_direct_mode(): assert accounts[0].access_strategy is AccountAccessStrategy.BASE_SESSION assert accounts[1].is_management is False assert accounts[1].access_strategy is AccountAccessStrategy.ASSUME_ROLE + assert accounts[1].role_name == DEFAULT_ORGANIZATION_ROLE_NAME assert accounts[0]._regions == ["us-east-1"] assert accounts[1]._regions == ["us-east-1"] diff --git a/tests/runner/test_provider_execution.py b/tests/runner/test_provider_execution.py index 0af0cad..0328ef6 100644 --- a/tests/runner/test_provider_execution.py +++ b/tests/runner/test_provider_execution.py @@ -39,7 +39,11 @@ def close(self) -> None: class _Provider: - metadata = ProviderMetadata(name="azure", display_name="Azure") + metadata = ProviderMetadata( + name="azure", + display_name="Azure", + supported_task_scopes=frozenset({"region", "target"}), + ) def __init__(self, *, calls: dict[str, object]) -> None: self._calls = calls @@ -74,6 +78,7 @@ def _execution_target( name=name or target_id, type="resource", provider="azure", + regions=regions or ["region-a"], provider_data=_ProviderData(locations=regions or ["region-a"]), ) @@ -87,7 +92,6 @@ def _context( ) -> ExecutionContext: return ExecutionContext( regions=regions or ["region-a"], - role_name=None, dry_run=False, tasks=tasks or [], metadata={}, @@ -378,7 +382,7 @@ def second(**kwargs): ] -def test_provider_sequential_regions_share_action_recorder() -> None: +def test_provider_regions_isolate_and_persist_task_actions() -> None: seen_actions: list[list[str]] = [] def run(**kwargs): @@ -398,7 +402,8 @@ def run(**kwargs): ) assert result.status is ExecutionStatus.SUCCESS - assert seen_actions == [[], ["region-a"]] + assert seen_actions == [[], []] + assert [task.actions for task in result.tasks] == [["region-a"], ["region-b"]] def test_provider_benchmark_records_entity_worker_metrics() -> None: diff --git a/tests/runner/test_runner_auth_pool.py b/tests/runner/test_runner_auth_pool.py index f143921..4074489 100644 --- a/tests/runner/test_runner_auth_pool.py +++ b/tests/runner/test_runner_auth_pool.py @@ -1,405 +1,273 @@ from __future__ import annotations -import importlib import threading import time from types import SimpleNamespace +import pytest -def _org_target(descriptors, *, name: str, profile: str): - return descriptors.TargetDescriptor( - config_branch=descriptors.ConfigBranch.TARGETS, name=name, profile=profile - ) +from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.execution_context import ExecutionContext +from anvil.providers.base import ProviderAuthResult, ProviderMetadata +from anvil.results import AuthResult, EngineState, ExecutionStatus, TargetResult +from anvil.runner import ( + PreparedTarget, + TargetExecutionOutcome, + run_auth_checks, + run_multiple_targets, +) -def _accounts_target(descriptors, *, name: str, profile: str, include: list[str]): - return descriptors.TargetDescriptor( - config_branch=descriptors.ConfigBranch.TARGETS, +def _target(*, name: str, profile: str, mode: str = "organization") -> TargetDescriptor: + return TargetDescriptor( + config_branch=ConfigBranch.TARGETS, name=name, - profile=profile, - include=include, - role_name="TestRole", + provider="test", + mode=mode, + provider_options={"profile": profile}, + include=["target-a"] if mode == "accounts" else None, + tasks=[], ) -def test_run_auth_checks_uses_parallel_pool_and_preserves_input_order(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") +class _AuthProvider: + metadata = ProviderMetadata( + name="test", + display_name="Test", + supported_task_scopes=frozenset({"region"}), + default_regions=("global",), + ) - started_count = 0 - max_in_flight = 0 - completed_order: list[str] = [] - lock = threading.Lock() - release_event = threading.Event() + def __init__(self, check) -> None: + self._check = check - targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), - _org_target(descriptors, name="org-c", profile="c"), - ] + def validate_target(self, target) -> None: + return None - monkeypatch.setattr( - runner, - "infer_auth_source", - lambda profile: SimpleNamespace(value=f"source-{profile}"), - ) + def resolve_target_filters(self, *, target, include_override, exclude_override): + return target.include, target.exclude - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - nonlocal started_count, max_in_flight + def auth_cache_key(self, target): + return ("test", target.provider_options["profile"]) - with lock: - started_count += 1 - max_in_flight = max(max_in_flight, started_count) - if started_count == len(targets): - release_event.set() + def auth_check(self, target): + return self._check(target) - assert release_event.wait(timeout=1.0) - time.sleep({"org-a": 0.03, "org-b": 0.0, "org-c": 0.01}[target_name]) +def _patch_provider(monkeypatch, check) -> None: + provider = _AuthProvider(check) + monkeypatch.setattr("anvil.runner._load_provider", lambda provider_name: provider) + + +def _success_auth(target: TargetDescriptor) -> ProviderAuthResult: + return ProviderAuthResult(status=ExecutionStatus.SUCCESS, source="test") - with lock: - completed_order.append(target_name) - started_count -= 1 - return results.AuthResult( - target_name=target_name, - status=results.ExecutionStatus.SUCCESS, - source=auth_source.value, +def _prepared( + *, + index: int, + target: TargetDescriptor, + exclusive_execution_keys: tuple[object, ...] = (), +) -> PreparedTarget: + return PreparedTarget( + index=index, + provider=SimpleNamespace(), + effective_target=target, + auth_result=AuthResult( + target_name=target.name, + status=ExecutionStatus.SUCCESS, + source="test", started_at="start", ended_at="end", duration_seconds=0.0, - message="ok", - ) - - monkeypatch.setattr(runner, "auth_check", fake_auth_check) + ), + context=ExecutionContext( + regions=["global"], dry_run=False, tasks=[], metadata={} + ), + exclusive_execution_keys=exclusive_execution_keys, + ) - engine_result = runner.run_auth_checks(targets=targets) - - assert max_in_flight > 1 - assert completed_order != ["org-a", "org-b", "org-c"] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - "org-c", - ] - assert engine_result.state is results.EngineState.COMPLETED_SUCCESS +def _outcome(prepared_target: PreparedTarget) -> TargetExecutionOutcome: + target = prepared_target.effective_target + return TargetExecutionOutcome( + index=prepared_target.index, + target_result=TargetResult.create( + config_branch=target.config_branch, + target_name=target.name, + provider=target.provider, + dry_run=False, + entities=[], + ), + cancelled=False, + ) -def test_run_auth_checks_handles_mixed_success_and_failure_in_input_order(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") +def test_run_auth_checks_uses_parallel_pool_and_preserves_input_order(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), - _org_target(descriptors, name="org-c", profile="c"), + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), + _target(name="target-c", profile="c"), ] + active = 0 + max_active = 0 + lock = threading.Lock() + release = threading.Event() - monkeypatch.setattr( - runner, - "infer_auth_source", - lambda profile: SimpleNamespace(value=f"source-{profile}"), - ) - - delays = {"org-a": 0.10, "org-b": 0.01, "org-c": 0.05} - statuses = { - "org-a": results.ExecutionStatus.SUCCESS, - "org-b": results.ExecutionStatus.ERROR, - "org-c": results.ExecutionStatus.SUCCESS, - } - - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - time.sleep(delays[target_name]) - return results.AuthResult( - target_name=target_name, - status=statuses[target_name], - source=auth_source.value, - started_at="start", - ended_at="end", - duration_seconds=delays[target_name], - message="ok" if statuses[target_name].is_success else "bad", - ) - - monkeypatch.setattr(runner, "auth_check", fake_auth_check) + def check(target): + nonlocal active, max_active + with lock: + active += 1 + max_active = max(max_active, active) + if active == len(targets): + release.set() + assert release.wait(timeout=1.0) + time.sleep({"target-a": 0.03, "target-b": 0.0, "target-c": 0.01}[target.name]) + with lock: + active -= 1 + return _success_auth(target) - engine_result = runner.run_auth_checks(targets=targets) + _patch_provider(monkeypatch, check) + result = run_auth_checks(targets=targets) - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - "org-c", - ] - assert [result.status for result in engine_result.auth_results] == [ - results.ExecutionStatus.SUCCESS, - results.ExecutionStatus.ERROR, - results.ExecutionStatus.SUCCESS, + assert max_active > 1 + assert [item.target_name for item in result.auth_results] == [ + "target-a", + "target-b", + "target-c", ] - assert engine_result.state is results.EngineState.AUTH_FAILED + assert result.state is EngineState.COMPLETED_SUCCESS -def test_run_auth_checks_reuses_same_profile_auth_result(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") - +def test_run_auth_checks_preserves_mixed_results_in_input_order(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="shared"), - _org_target(descriptors, name="org-b", profile="shared"), - _org_target(descriptors, name="org-c", profile="shared"), + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), + _target(name="target-c", profile="c"), ] - auth_check_calls: list[str] = [] - monkeypatch.setattr( - runner, "infer_auth_source", lambda profile: runner.AuthSource.PROFILE_STATIC - ) - - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - auth_check_calls.append(target_name) - return results.AuthResult( - target_name=target_name, - status=results.ExecutionStatus.SUCCESS, - source=auth_source.value, - started_at="start", - ended_at="end", - duration_seconds=1.0, - message="ok", + def check(target): + status = ( + ExecutionStatus.ERROR + if target.name == "target-b" + else ExecutionStatus.SUCCESS ) + return ProviderAuthResult(status=status, source="test", message=target.name) - monkeypatch.setattr(runner, "auth_check", fake_auth_check) - - engine_result = runner.run_auth_checks(targets=targets) + _patch_provider(monkeypatch, check) + result = run_auth_checks(targets=targets) - assert auth_check_calls == ["org-a"] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - "org-c", + assert [item.status for item in result.auth_results] == [ + ExecutionStatus.SUCCESS, + ExecutionStatus.ERROR, + ExecutionStatus.SUCCESS, ] - assert [result.duration_seconds for result in engine_result.auth_results] == [ - 1.0, - 0.0, - 0.0, - ] - assert all( - result.status is results.ExecutionStatus.SUCCESS - for result in engine_result.auth_results - ) + assert result.state is EngineState.AUTH_FAILED -def test_run_auth_checks_reuses_same_profile_failure_for_each_target(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") - +@pytest.mark.parametrize("status", [ExecutionStatus.SUCCESS, ExecutionStatus.ERROR]) +def test_run_auth_checks_reuses_same_provider_cache_key(monkeypatch, status): targets = [ - _org_target(descriptors, name="org-a", profile="shared"), - _org_target(descriptors, name="org-b", profile="shared"), + _target(name="target-a", profile="shared"), + _target(name="target-b", profile="shared"), + _target(name="target-c", profile="shared"), ] - auth_check_calls: list[str] = [] + calls: list[str] = [] - monkeypatch.setattr( - runner, "infer_auth_source", lambda profile: runner.AuthSource.SSO - ) + def check(target): + calls.append(target.name) + return ProviderAuthResult(status=status, source="test", message="cached") - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - auth_check_calls.append(target_name) - return results.AuthResult( - target_name=target_name, - status=results.ExecutionStatus.ERROR, - source=auth_source.value, - started_at="start", - ended_at="end", - duration_seconds=1.0, - message="AWS SSO session is invalid or expired.", - remediation="aws sso login --profile shared", - ) - - monkeypatch.setattr(runner, "auth_check", fake_auth_check) + _patch_provider(monkeypatch, check) + result = run_auth_checks(targets=targets) - engine_result = runner.run_auth_checks(targets=targets) - - assert auth_check_calls == ["org-a"] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - ] - assert [result.status for result in engine_result.auth_results] == [ - results.ExecutionStatus.ERROR, - results.ExecutionStatus.ERROR, + assert calls == ["target-a"] + assert [item.target_name for item in result.auth_results] == [ + "target-a", + "target-b", + "target-c", ] - assert [result.duration_seconds for result in engine_result.auth_results] == [ - 1.0, - 0.0, - ] - assert all( - result.message == "AWS SSO session is invalid or expired." - for result in engine_result.auth_results - ) - assert engine_result.state is results.EngineState.AUTH_FAILED - + assert all(item.status is status for item in result.auth_results) + assert [item.duration_seconds for item in result.auth_results][1:] == [0.0, 0.0] -def test_run_auth_checks_keeps_different_profiles_separate(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") +def test_run_auth_checks_keeps_distinct_cache_keys_separate(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), - _org_target(descriptors, name="org-c", profile="a"), + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), + _target(name="target-c", profile="a"), ] - auth_check_calls: list[tuple[str, str | None]] = [] + calls: list[str] = [] - monkeypatch.setattr( - runner, "infer_auth_source", lambda profile: runner.AuthSource.PROFILE_STATIC - ) + def check(target): + calls.append(target.name) + return _success_auth(target) - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - auth_check_calls.append((target_name, profile)) - return results.AuthResult( - target_name=target_name, - status=results.ExecutionStatus.SUCCESS, - source=auth_source.value, - started_at="start", - ended_at="end", - duration_seconds=1.0, - message="ok", - ) + _patch_provider(monkeypatch, check) + run_auth_checks(targets=targets) - monkeypatch.setattr(runner, "auth_check", fake_auth_check) + assert calls == ["target-a", "target-b"] - engine_result = runner.run_auth_checks(targets=targets) - - assert auth_check_calls == [("org-a", "a"), ("org-b", "b")] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - "org-c", - ] - - -def test_run_auth_checks_single_flights_concurrent_same_profile(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") +def test_run_auth_checks_single_flights_concurrent_same_key(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="shared"), - _org_target(descriptors, name="org-b", profile="shared"), + _target(name="target-a", profile="shared"), + _target(name="target-b", profile="shared"), ] - auth_check_calls: list[str] = [] - auth_check_started = threading.Event() - release_auth_check = threading.Event() - - monkeypatch.setattr( - runner, "infer_auth_source", lambda profile: runner.AuthSource.PROFILE_STATIC - ) - - def fake_auth_check(*, target_name: str, profile: str | None, auth_source): - auth_check_calls.append(target_name) - auth_check_started.set() - assert release_auth_check.wait(timeout=1.0) - return results.AuthResult( - target_name=target_name, - status=results.ExecutionStatus.SUCCESS, - source=auth_source.value, - started_at="start", - ended_at="end", - duration_seconds=1.0, - message="ok", - ) - - monkeypatch.setattr(runner, "auth_check", fake_auth_check) - - result_holder = {} + calls: list[str] = [] + started = threading.Event() + release = threading.Event() + + def check(target): + calls.append(target.name) + started.set() + assert release.wait(timeout=1.0) + return _success_auth(target) + + _patch_provider(monkeypatch, check) + holder = {} thread = threading.Thread( - target=lambda: result_holder.setdefault( - "result", runner.run_auth_checks(targets=targets) - ) + target=lambda: holder.setdefault("result", run_auth_checks(targets=targets)) ) thread.start() - - assert auth_check_started.wait(timeout=1.0) - release_auth_check.set() + assert started.wait(timeout=1.0) + release.set() thread.join(timeout=1.0) assert not thread.is_alive() - assert auth_check_calls == ["org-a"] - assert [result.target_name for result in result_holder["result"].auth_results] == [ - "org-a", - "org-b", - ] - - -def test_run_multiple_targets_executes_targets_in_parallel_and_preserves_input_order( - monkeypatch, -): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") + assert calls == ["target-a"] + assert len(holder["result"].auth_results) == 2 - started_count = 0 - max_in_flight = 0 - completed_order: list[str] = [] - lock = threading.Lock() - release_event = threading.Event() +def test_run_multiple_targets_parallelizes_and_preserves_result_order(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), ] + active = 0 + max_active = 0 + lock = threading.Lock() + release = threading.Event() - def fake_prepare_target(*, index, target, **kwargs): - return runner.PreparedTarget( - index=index, - effective_target=target, - auth_result=results.AuthResult( - target_name=target.name, - status=results.ExecutionStatus.SUCCESS, - source=f"source-{target.profile}", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=SimpleNamespace(cancel_event=threading.Event(), dry_run=False), - organization_id=target.name, - management_account_id="123456789012", - ) - - def fake_run_prepared_target(*, prepared_target): - nonlocal started_count, max_in_flight + monkeypatch.setattr( + "anvil.runner.prepare_target", + lambda index, target, **kwargs: _prepared(index=index, target=target), + ) + def run(prepared_target): + nonlocal active, max_active with lock: - started_count += 1 - max_in_flight = max(max_in_flight, started_count) - if started_count == len(targets): - release_event.set() - - assert release_event.wait(timeout=1.0) - time.sleep({"org-a": 0.03, "org-b": 0.0}[prepared_target.effective_target.name]) - + active += 1 + max_active = max(max_active, active) + if active == len(targets): + release.set() + assert release.wait(timeout=1.0) with lock: - completed_order.append(prepared_target.effective_target.name) - started_count -= 1 - - return runner.TargetExecutionOutcome( - index=prepared_target.index, - target_result=results.TargetResult.create( - config_branch=prepared_target.effective_target.config_branch, - target_name=prepared_target.effective_target.name, - dry_run=False, - entities=[], - ), - cancelled=False, - ) + active -= 1 + return _outcome(prepared_target) - monkeypatch.setattr(runner, "prepare_target", fake_prepare_target) - monkeypatch.setattr(runner, "run_prepared_target", fake_run_prepared_target) - - engine_result = runner.run_multiple_targets( + monkeypatch.setattr("anvil.runner.run_prepared_target", run) + result = run_multiple_targets( targets=targets, max_parallel_targets=2, cli_dry_run=None, @@ -407,169 +275,53 @@ def fake_run_prepared_target(*, prepared_target): cli_exclude=None, ) - assert max_in_flight > 1 - assert completed_order != ["org-a", "org-b"] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - ] - assert [result.target_name for result in engine_result.target_results] == [ - "org-a", - "org-b", + assert max_active == 2 + assert [item.target_name for item in result.target_results] == [ + "target-a", + "target-b", ] - assert engine_result.state is results.EngineState.COMPLETED_SUCCESS - -def test_run_multiple_targets_serializes_same_org_targets(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") - - active_org_counts: dict[str, int] = {} - max_same_org = 0 - max_total_in_flight = 0 - total_in_flight = 0 - lock = threading.Lock() +def test_run_multiple_targets_serializes_overlapping_provider_keys(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), - _org_target(descriptors, name="org-c", profile="c"), - ] - org_ids = {"org-a": "shared-org", "org-b": "shared-org", "org-c": "other-org"} - - def fake_prepare_target(*, index, target, **kwargs): - return runner.PreparedTarget( - index=index, - effective_target=target, - auth_result=results.AuthResult( - target_name=target.name, - status=results.ExecutionStatus.SUCCESS, - source=f"source-{target.profile}", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=SimpleNamespace(cancel_event=threading.Event(), dry_run=False), - organization_id=org_ids[target.name], - management_account_id="123456789012", - ) - - def fake_run_prepared_target(*, prepared_target): - nonlocal max_same_org, max_total_in_flight, total_in_flight - organization_id = prepared_target.organization_id or "" - - with lock: - total_in_flight += 1 - active_org_counts[organization_id] = ( - active_org_counts.get(organization_id, 0) + 1 - ) - max_same_org = max(max_same_org, active_org_counts[organization_id]) - max_total_in_flight = max(max_total_in_flight, total_in_flight) - - time.sleep(0.03) - - with lock: - total_in_flight -= 1 - active_org_counts[organization_id] -= 1 - - return runner.TargetExecutionOutcome( - index=prepared_target.index, - target_result=results.TargetResult.create( - config_branch=prepared_target.effective_target.config_branch, - target_name=prepared_target.effective_target.name, - dry_run=False, - entities=[], - ), - cancelled=False, - ) - - monkeypatch.setattr(runner, "prepare_target", fake_prepare_target) - monkeypatch.setattr(runner, "run_prepared_target", fake_run_prepared_target) - - engine_result = runner.run_multiple_targets( - targets=targets, - max_parallel_targets=2, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - assert max_same_org == 1 - assert max_total_in_flight == 2 - assert [result.target_name for result in engine_result.target_results] == [ - "org-a", - "org-b", - "org-c", + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), + _target(name="target-c", profile="c"), ] - - -def test_run_multiple_targets_parallelizes_accounts_branch(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") - - started_count = 0 - max_in_flight = 0 + keys = { + "target-a": (("test", "shared"),), + "target-b": (("test", "shared"),), + "target-c": (("test", "other"),), + } + active_by_key: dict[object, int] = {} + max_shared = 0 + max_total = 0 + total = 0 lock = threading.Lock() - release_event = threading.Event() - targets = [ - _accounts_target( - descriptors, name="group-a", profile="a", include=["111111111111"] - ), - _accounts_target( - descriptors, name="group-b", profile="b", include=["222222222222"] + monkeypatch.setattr( + "anvil.runner.prepare_target", + lambda index, target, **kwargs: _prepared( + index=index, target=target, exclusive_execution_keys=keys[target.name] ), - ] - - def fake_prepare_target(*, index, target, **kwargs): - return runner.PreparedTarget( - index=index, - effective_target=target, - auth_result=results.AuthResult( - target_name=target.name, - status=results.ExecutionStatus.SUCCESS, - source=f"source-{target.profile}", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=SimpleNamespace(cancel_event=threading.Event(), dry_run=False), - ) - - def fake_run_prepared_target(*, prepared_target): - nonlocal started_count, max_in_flight + ) + def run(prepared_target): + nonlocal max_shared, max_total, total + key = prepared_target.exclusive_execution_keys[0] with lock: - started_count += 1 - max_in_flight = max(max_in_flight, started_count) - if started_count == len(targets): - release_event.set() - - assert release_event.wait(timeout=1.0) - time.sleep(0.02) - + total += 1 + active_by_key[key] = active_by_key.get(key, 0) + 1 + max_shared = max(max_shared, active_by_key.get(("test", "shared"), 0)) + max_total = max(max_total, total) + time.sleep(0.03) with lock: - started_count -= 1 - - return runner.TargetExecutionOutcome( - index=prepared_target.index, - target_result=results.TargetResult.create( - config_branch=prepared_target.effective_target.config_branch, - target_name=prepared_target.effective_target.name, - dry_run=False, - entities=[], - ), - cancelled=False, - ) + total -= 1 + active_by_key[key] -= 1 + return _outcome(prepared_target) - monkeypatch.setattr(runner, "prepare_target", fake_prepare_target) - monkeypatch.setattr(runner, "run_prepared_target", fake_run_prepared_target) - - engine_result = runner.run_multiple_targets( + monkeypatch.setattr("anvil.runner.run_prepared_target", run) + run_multiple_targets( targets=targets, max_parallel_targets=2, cli_dry_run=None, @@ -577,75 +329,36 @@ def fake_run_prepared_target(*, prepared_target): cli_exclude=None, ) - assert max_in_flight > 1 - assert [result.target_name for result in engine_result.target_results] == [ - "group-a", - "group-b", - ] - + assert max_shared == 1 + assert max_total == 2 -def test_run_multiple_targets_pipelines_preparation_into_execution(monkeypatch): - runner = importlib.import_module("anvil.runner") - descriptors = importlib.import_module("anvil.descriptors") - results = importlib.import_module("anvil.results") - - prep_done = threading.Event() - first_execution_started = threading.Event() +def test_run_multiple_targets_pipelines_preparation_and_execution(monkeypatch): targets = [ - _org_target(descriptors, name="org-a", profile="a"), - _org_target(descriptors, name="org-b", profile="b"), - _org_target(descriptors, name="org-c", profile="c"), + _target(name="target-a", profile="a"), + _target(name="target-b", profile="b"), + _target(name="target-c", profile="c"), ] + first_execution_started = threading.Event() + last_preparation_finished = threading.Event() - def fake_prepare_target(*, index, target, **kwargs): - if target.name == "org-c": + def prepare(index, target, **kwargs): + if target.name == "target-c": assert first_execution_started.wait(timeout=1.0) - time.sleep(0.03) - - prepared = runner.PreparedTarget( - index=index, - effective_target=target, - auth_result=results.AuthResult( - target_name=target.name, - status=results.ExecutionStatus.SUCCESS, - source=f"source-{target.profile}", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=SimpleNamespace(cancel_event=threading.Event(), dry_run=False), - organization_id=target.name, - management_account_id="123456789012", - ) - - if target.name == "org-c": - prep_done.set() + last_preparation_finished.set() + return _prepared(index=index, target=target) - return prepared - - def fake_run_prepared_target(*, prepared_target): - if prepared_target.effective_target.name == "org-a": + def run(prepared_target): + if prepared_target.effective_target.name == "target-a": first_execution_started.set() - assert not prep_done.is_set() - + assert not last_preparation_finished.is_set() time.sleep(0.01) - return runner.TargetExecutionOutcome( - index=prepared_target.index, - target_result=results.TargetResult.create( - config_branch=prepared_target.effective_target.config_branch, - target_name=prepared_target.effective_target.name, - dry_run=False, - entities=[], - ), - cancelled=False, - ) + return _outcome(prepared_target) - monkeypatch.setattr(runner, "prepare_target", fake_prepare_target) - monkeypatch.setattr(runner, "run_prepared_target", fake_run_prepared_target) + monkeypatch.setattr("anvil.runner.prepare_target", prepare) + monkeypatch.setattr("anvil.runner.run_prepared_target", run) - engine_result = runner.run_multiple_targets( + result = run_multiple_targets( targets=targets, max_parallel_targets=2, cli_dry_run=None, @@ -654,9 +367,5 @@ def fake_run_prepared_target(*, prepared_target): ) assert first_execution_started.is_set() - assert prep_done.is_set() - assert [result.target_name for result in engine_result.target_results] == [ - "org-a", - "org-b", - "org-c", - ] + assert last_preparation_finished.is_set() + assert len(result.target_results) == 3 diff --git a/tests/runner/test_runner_flow.py b/tests/runner/test_runner_flow.py index 555e722..56688e3 100644 --- a/tests/runner/test_runner_flow.py +++ b/tests/runner/test_runner_flow.py @@ -1,1705 +1,456 @@ -import sys +from __future__ import annotations + import threading from collections import deque -from concurrent.futures import ThreadPoolExecutor -from types import ModuleType from types import SimpleNamespace -import pytest - -from anvil.auth import AuthSource from anvil.descriptors import ConfigBranch, TargetDescriptor from anvil.execution_context import ExecutionContext -from anvil.providers.base import ProviderRegion -from anvil.providers.azure.provider import AzureSubscription -from anvil.providers.gcp.provider import GcpProject -from anvil.results import EntityResult, AuthResult, ExecutionStatus, TargetResult +from anvil.providers.base import ( + ExecutionTarget, + ProviderAuthResult, + ProviderExecutionPlan, + ProviderMetadata, + ProviderPreparation, +) +from anvil.results import EntityResult, EngineState, ExecutionStatus from anvil.runner import ( AuthCheckCache, - OrganizationRunCache, - OrganizationRunCacheEntry, PreparedTarget, + _SingleFlightCache, + _execute_provider_targets, _next_eligible_target, prepare_target, - run_auth_checks, run_multiple_targets, run_prepared_target, ) from anvil.task_loader import ResolvedExecution, ResolvedTask -from anvil.validators import load_config_descriptors, validate_config_schema -def _empty_resolved_execution(**kwargs): - return ResolvedExecution(ordered=[], adjacency={}) +def _target(**overrides) -> TargetDescriptor: + values = { + "config_branch": ConfigBranch.TARGETS, + "name": "target-a", + "provider": "test", + "mode": "fleet", + "regions": ["global"], + "tasks": [], + } + values.update(overrides) + return TargetDescriptor(**values) -def _load_targets(config: dict) -> list[TargetDescriptor]: - validate_config_schema(config=config) - return load_config_descriptors(config=config).targets +class _Runtime: + def __init__(self, *, fail_session: bool = False) -> None: + self.fail_session = fail_session + def build_session(self, *, region: str): + if self.fail_session: + raise RuntimeError("session failed") + return SimpleNamespace(region_name=region) -@pytest.fixture(autouse=True) -def fake_azure_identity_dependency(monkeypatch): - class FakeDefaultAzureCredential: - def __init__(self, **kwargs): - self.kwargs = kwargs + def record_region_outcome(self, **kwargs) -> None: + return None - def get_token(self, scope): - return SimpleNamespace(token=f"token:{scope}") + def close(self) -> None: + return None - class FakeClientSecretCredential(FakeDefaultAzureCredential): - def __init__(self, *, tenant_id, client_id, client_secret): - super().__init__( - tenant_id=tenant_id, client_id=client_id, client_secret=client_secret - ) - azure_module = ModuleType("azure") - identity_module = ModuleType("azure.identity") - identity_module.ClientSecretCredential = FakeClientSecretCredential - identity_module.DefaultAzureCredential = FakeDefaultAzureCredential - azure_module.identity = identity_module - monkeypatch.setitem(sys.modules, "azure", azure_module) - monkeypatch.setitem(sys.modules, "azure.identity", identity_module) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.list_locations", - lambda self, **kwargs: [ProviderRegion(name="eastus", status="available")], +class _Provider: + metadata = ProviderMetadata( + name="test", + display_name="Test", + supported_task_scopes=frozenset({"region", "target"}), + default_regions=("global",), ) - -def test_runner_auth_failure_short_circuits(monkeypatch): - monkeypatch.setattr( - "anvil.runner.auth_check", - lambda **kwargs: AuthResult( - target_name=kwargs["target_name"], - status=ExecutionStatus.ERROR, + def __init__( + self, + *, + auth_status: ExecutionStatus = ExecutionStatus.SUCCESS, + fail_session: bool = False, + fail_resolution: bool = False, + ) -> None: + self.auth_status = auth_status + self.fail_session = fail_session + self.fail_resolution = fail_resolution + self.preparation = object() + self.seen_preparation = None + + def validate_target(self, target) -> None: + return None + + def resolve_target_filters(self, *, target, include_override, exclude_override): + include = include_override if include_override is not None else target.include + exclude = exclude_override if exclude_override is not None else target.exclude + if include is not None and exclude is not None: + raise ValueError("test filters are mutually exclusive") + return include, exclude + + def auth_cache_key(self, target): + return ("test", target.name) + + def auth_check(self, target): + return ProviderAuthResult( + status=self.auth_status, source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="fail", - ), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", - tasks=[], - regions=["us-east-1"], - role_name="role", - max_workers=1, - dry_run=True, - fail_fast=True, - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=True, - cli_include=None, - cli_exclude=None, - ) - - assert engine_result.auth_results[0].status is ExecutionStatus.ERROR - assert engine_result.target_results == [] - assert engine_result.has_auth_failures - - -def test_run_dispatches_non_aws_provider_without_aws_auth_or_preflight(monkeypatch): - def fail_auth_check(**kwargs): - raise AssertionError("AWS auth should not run for non-AWS providers") - - monkeypatch.setattr("anvil.runner.auth_check", fail_auth_check) - monkeypatch.setattr( - "anvil.runner.AwsProvider.preflight_execution", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS organization preflight should not run") - ), - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: type( - "_AzureSession", (), {"region_name": kwargs["location"]} - )(), - ) - resolved_provider_names: list[str] = [] - - def fake_resolve_tasks(**kwargs): - resolved_provider_names.append(kwargs["provider_name"]) - return ResolvedExecution(ordered=[], adjacency={}) - - monkeypatch.setattr("anvil.runner.resolve_tasks", fake_resolve_tasks) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="subscriptions", - include=["11111111-2222-3333-4444-555555555555"], - tasks=[], - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - assert resolved_provider_names == ["azure"] - assert engine_result.auth_results[0].status is ExecutionStatus.SUCCESS - assert engine_result.auth_results[0].source == "azure" - assert engine_result.target_results[0].entities[0].id == ( - "11111111-2222-3333-4444-555555555555" - ) - - -def test_auth_check_dispatches_non_aws_provider_without_aws_auth(monkeypatch): - def fail_auth_check(**kwargs): - raise AssertionError("AWS auth should not run for non-AWS providers") - - monkeypatch.setattr("anvil.runner.auth_check", fail_auth_check) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="gcp-projects", - provider="gcp", - mode="projects", - include=["project-a"], - tasks=[], - ) - - engine_result = run_auth_checks(targets=[target]) - - assert engine_result.auth_results[0].status is ExecutionStatus.SUCCESS - assert engine_result.auth_results[0].source == "deferred" - - -def test_non_aws_provider_session_failure_is_reported_without_aws_paths(monkeypatch): - monkeypatch.setattr( - "anvil.runner.auth_check", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS auth should not run for non-AWS providers") - ), - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: (_ for _ in ()).throw( - RuntimeError("Azure provider requires optional dependency 'azure-identity'") - ), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="subscriptions", - include=["11111111-2222-3333-4444-555555555555"], - tasks=[{"name": "noop"}], - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - entity_result = engine_result.target_results[0].entities[0] - assert entity_result.status is ExecutionStatus.ERROR - assert "azure-identity" in entity_result.error + message="auth failed" if self.auth_status.is_error else "ok", + ) + def prepare_target(self, **kwargs): + return ProviderPreparation( + data=self.preparation, + exclusive_execution_keys=(("test", kwargs["target"].name),), + ) -def test_non_aws_provider_options_reach_runtime_session_factory(monkeypatch): - session_calls: list[dict[str, str | None]] = [] + def resolve_execution_targets(self, **kwargs): + if self.fail_resolution: + raise RuntimeError("resolution failed") + self.seen_preparation = kwargs["preparation"] + target = kwargs["target"] + return ProviderExecutionPlan( + execution_targets=[ + ExecutionTarget( + id="entity-a", + name="Entity A", + type="resource", + provider="test", + regions=list(kwargs["regions"]), + metadata={"target": target.name}, + ) + ] + ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: ( - session_calls.append(kwargs) - or type("_AzureSession", (), {"region_name": kwargs["location"]})() - ), - ) + def prepare_execution_runtime(self, **kwargs): + return _Runtime(fail_session=self.fail_session) - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="subscriptions", - regions=["eastus"], - include=["sub-a"], - provider_options={ - "tenant_id": "tenant-a", - "client_id": "client-a", - "client_secret": "secret-a", - }, - tasks=[], - ) - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) +def _patch_provider(monkeypatch, provider: _Provider) -> None: + monkeypatch.setattr("anvil.runner._load_provider", lambda provider_name: provider) - assert not engine_result.target_results[0].has_failures - assert session_calls == [ - { - "subscription_id": "sub-a", - "location": "eastus", - "tenant_id": "tenant-a", - "client_id": "client-a", - "client_secret": "secret-a", - } - ] +def test_auth_failure_short_circuits_target_execution(monkeypatch): + provider = _Provider(auth_status=ExecutionStatus.ERROR) + _patch_provider(monkeypatch, provider) -def test_azure_subscription_discovery_runs_without_aws_paths(monkeypatch): - subscription_calls: list[dict[str, str | None]] = [] - - monkeypatch.setattr( - "anvil.runner.auth_check", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS auth should not run for Azure providers") - ), - ) - monkeypatch.setattr( - "anvil.runner.AwsProvider.preflight_execution", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS organization preflight should not run") - ), - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.list_subscriptions", - lambda self, **kwargs: ( - subscription_calls.append(kwargs) - or [ - AzureSubscription(subscription_id="sub-b", display_name="Sub B"), - AzureSubscription(subscription_id="sub-a", display_name="Sub A"), - ] - ), - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: type( - "_AzureSession", (), {"region_name": kwargs["location"]} - )(), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="tenant", - include=None, - tasks=[], - ) - - engine_result = run_multiple_targets( - targets=[target], + result = run_multiple_targets( + targets=[_target()], max_parallel_targets=1, cli_dry_run=None, cli_include=None, cli_exclude=None, ) - assert [result.id for result in engine_result.target_results[0].entities] == [ - "sub-a", - "sub-b", - ] - assert [result.name for result in engine_result.target_results[0].entities] == [ - "Sub A", - "Sub B", - ] - assert subscription_calls == [ - {"tenant_id": None, "client_id": None, "client_secret": None} - ] - + assert result.state is EngineState.AUTH_FAILED + assert result.target_results == [] + assert result.auth_results[0].message == "auth failed" -def test_azure_subscription_discovery_errors_are_target_failures(monkeypatch): - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.list_subscriptions", - lambda self, **kwargs: (_ for _ in ()).throw( - RuntimeError("Azure provider could not discover subscriptions: denied") - ), - ) - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="tenant", - include=None, - provider_options={ - "tenant_id": "error-tenant", - "client_id": "error-client", - "client_secret": "error-secret", - }, - tasks=[], - ) +def test_provider_dispatch_executes_resolved_target_without_aws_paths(monkeypatch): + provider = _Provider() + _patch_provider(monkeypatch, provider) - engine_result = run_multiple_targets( - targets=[target], + result = run_multiple_targets( + targets=[_target()], max_parallel_targets=1, cli_dry_run=None, cli_include=None, cli_exclude=None, ) - target_result = engine_result.target_results[0] - assert ( - target_result.error == "Azure provider could not discover subscriptions: denied" - ) - assert target_result.entities == [] + assert result.state is EngineState.COMPLETED_SUCCESS + assert [entity.id for entity in result.target_results[0].entities] == ["entity-a"] + assert provider.seen_preparation is provider.preparation -def test_azure_subscription_discovery_rejects_cli_include_and_exclude(monkeypatch): - def unexpected_list_subscriptions(self, **kwargs): - raise AssertionError("subscription discovery should not run") +def test_provider_session_failure_becomes_entity_error(monkeypatch): + provider = _Provider(fail_session=True) + _patch_provider(monkeypatch, provider) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.list_subscriptions", - unexpected_list_subscriptions, - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: type( - "_AzureSession", (), {"region_name": kwargs["location"]} - )(), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="tenant", - include=None, - provider_options={ - "tenant_id": "cli-filter-tenant", - "client_id": "cli-filter-client", - "client_secret": "cli-filter-secret", - }, - tasks=[], - ) - - engine_result = run_multiple_targets( - targets=[target], + result = run_multiple_targets( + targets=[_target()], max_parallel_targets=1, cli_dry_run=None, - cli_include=["sub-c", "sub-a"], - cli_exclude=["sub-a"], - ) - - assert engine_result.target_results == [] - assert engine_result.auth_results[0].status is ExecutionStatus.ERROR - assert engine_result.auth_results[0].source == "config" - assert "include and exclude together" in engine_result.auth_results[0].message - - -def test_azure_subscription_discovery_plan_is_cached_across_targets(monkeypatch): - subscription_calls = 0 - - def fake_list_subscriptions(self, **kwargs): - nonlocal subscription_calls - subscription_calls += 1 - return [AzureSubscription(subscription_id="sub-a")] - - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.list_subscriptions", - fake_list_subscriptions, - ) - monkeypatch.setattr( - "anvil.providers.azure.provider.AzureSessionFactory.create_session", - lambda self, **kwargs: type( - "_AzureSession", (), {"region_name": kwargs["location"]} - )(), - ) - - targets = [ - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions-a", - provider="azure", - mode="tenant", - include=None, - provider_options={ - "tenant_id": "cache-tenant", - "client_id": "cache-client", - "client_secret": "cache-secret", - }, - tasks=[], - ), - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions-b", - provider="azure", - mode="tenant", - include=None, - provider_options={ - "tenant_id": "cache-tenant", - "client_id": "cache-client", - "client_secret": "cache-secret", - }, - tasks=[], - ), - ] - - engine_result = run_multiple_targets( - targets=targets, - max_parallel_targets=2, - cli_dry_run=None, cli_include=None, cli_exclude=None, ) - assert subscription_calls == 1 - assert [result.entities[0].id for result in engine_result.target_results] == [ - "sub-a", - "sub-a", - ] - + entity = result.target_results[0].entities[0] + assert entity.status is ExecutionStatus.ERROR + assert entity.error == "session failed" -def test_scheduler_blocks_targets_with_overlapping_azure_execution_keys(): - context = ExecutionContext( - regions=["eastus"], role_name=None, dry_run=False, tasks=[], metadata={} - ) - auth_result = AuthResult( - target_name="azure-a", - status=ExecutionStatus.SUCCESS, - source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ) - overlapping_target = PreparedTarget( - index=0, - effective_target=TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-a", - provider="azure", - mode="subscriptions", - include=["sub-a", "sub-b"], - ), - auth_result=auth_result, - context=context, - exclusive_execution_keys=( - ("azure", "subscription", "sub-a"), - ("azure", "subscription", "sub-b"), - ), - ) - non_overlapping_target = PreparedTarget( - index=1, - effective_target=TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-b", - provider="azure", - mode="subscriptions", - include=["sub-c"], - ), - auth_result=auth_result, - context=context, - exclusive_execution_keys=(("azure", "subscription", "sub-c"),), - ) - pending = deque([overlapping_target, non_overlapping_target]) - selected = _next_eligible_target( - pending=pending, active_execution_keys={("azure", "subscription", "sub-b")} - ) - - assert selected is non_overlapping_target - assert list(pending) == [overlapping_target] - - -def test_non_aws_universal_task_can_use_provider_neutral_kwargs(monkeypatch): - seen: dict[str, object] = {} +def test_universal_task_receives_provider_neutral_kwargs_and_records_actions( + monkeypatch, +): + provider = _Provider() + _patch_provider(monkeypatch, provider) + seen = {} - def neutral_task( + def task( *, provider, execution_target_id, execution_target_name, execution_target_type, region, - task_context, session, dry_run, metadata, actions, ): - seen.update( - { - "provider": provider, - "execution_target_id": execution_target_id, - "execution_target_name": execution_target_name, - "execution_target_type": execution_target_type, - "region": region, - "context_provider": task_context.provider, - "session_region": session.region_name, - "dry_run": dry_run, - "metadata": metadata, - "actions": actions, - } - ) - actions.record("neutral task ran") - return {"provider": provider, "target": execution_target_id} + seen.update(locals()) + actions.record("task ran") + return {"target": execution_target_id} monkeypatch.setattr( "anvil.runner.resolve_tasks", lambda **kwargs: ResolvedExecution( - ordered=[ - ResolvedTask("neutral", neutral_task, depends_on=[], optional=False) - ], + ordered=[ResolvedTask("neutral", task, depends_on=[], optional=False)], adjacency={}, ), ) - monkeypatch.setattr( - "anvil.providers.gcp.provider.GcpSessionFactory.create_session", - lambda self, **kwargs: type( - "_GcpSession", (), {"region_name": kwargs["location"]} - )(), - ) - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="gcp-projects", - provider="gcp", - mode="projects", - regions=["us-central1"], - include=["project-a"], - metadata={"source": "neutral"}, - tasks=[{"name": "neutral"}], - ) - - engine_result = run_multiple_targets( - targets=[target], + result = run_multiple_targets( + targets=[_target(tasks=[{"name": "neutral"}], metadata={"team": "security"})], max_parallel_targets=1, cli_dry_run=None, cli_include=None, cli_exclude=None, ) - entity_result = engine_result.target_results[0].entities[0] - assert entity_result.status is ExecutionStatus.SUCCESS - assert entity_result.tasks[0].result == {"provider": "gcp", "target": "project-a"} - assert seen["provider"] == "gcp" - assert seen["execution_target_id"] == "project-a" - assert seen["execution_target_name"] == "project-a" - assert seen["execution_target_type"] == "project" - assert seen["region"] == "us-central1" - assert seen["context_provider"] == "gcp" - assert seen["session_region"] == "us-central1" + task_result = result.target_results[0].entities[0].tasks[0] + assert task_result.status is ExecutionStatus.SUCCESS + assert task_result.actions == ["task ran"] + assert seen["provider"] == "test" + assert seen["execution_target_id"] == "entity-a" + assert seen["execution_target_name"] == "Entity A" + assert seen["execution_target_type"] == "resource" + assert seen["region"] == "global" + assert seen["session"].region_name == "global" assert seen["dry_run"] is False - assert seen["metadata"] == {"source": "neutral"} - assert seen["actions"].actions == ["neutral task ran"] - - -def test_non_aws_execution_uses_provider_resolved_target_locations(monkeypatch): - seen: list[tuple[str, str]] = [] - - def neutral_task(*, execution_target_id, region, **kwargs): - seen.append((execution_target_id, region)) - return {"target": execution_target_id, "region": region} - - monkeypatch.setattr( - "anvil.runner.resolve_tasks", - lambda **kwargs: ResolvedExecution( - ordered=[ - ResolvedTask("neutral", neutral_task, depends_on=[], optional=False) - ], - adjacency={}, - ), - ) - monkeypatch.setattr( - "anvil.providers.gcp.provider.GcpSessionFactory.create_session", - lambda self, **kwargs: type( - "_GcpSession", (), {"region_name": kwargs["location"]} - )(), - ) - monkeypatch.setattr( - "anvil.providers.gcp.provider.GcpSessionFactory.list_regions", - lambda self, *, project_id, **kwargs: ( - [ - ProviderRegion(name="us-east1", status="UP"), - ProviderRegion(name="us-west1", status="UP"), - ] - if project_id == "project-a" - else [ProviderRegion(name="europe-west1", status="UP")] - ), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="gcp-projects", - provider="gcp", - mode="projects", - regions=["all"], - include=["project-a", "project-b"], - tasks=[{"name": "neutral"}], - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - assert not engine_result.target_results[0].has_failures - assert sorted(seen) == [ - ("project-a", "us-east1"), - ("project-a", "us-west1"), - ("project-b", "europe-west1"), - ] - - -def test_gcp_project_discovery_runs_without_aws_paths(monkeypatch): - project_calls: list[dict[str, str | None]] = [] - - monkeypatch.setattr( - "anvil.runner.auth_check", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS auth should not run for GCP providers") - ), - ) - monkeypatch.setattr( - "anvil.runner.AwsProvider.preflight_execution", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("AWS organization preflight should not run") - ), - ) - monkeypatch.setattr( - "anvil.providers.gcp.provider.GcpSessionFactory.list_projects", - lambda self, **kwargs: ( - project_calls.append(kwargs) - or [GcpProject(project_id="project-b"), GcpProject(project_id="project-a")] - ), - ) - monkeypatch.setattr( - "anvil.providers.gcp.provider.GcpSessionFactory.create_session", - lambda self, **kwargs: type( - "_GcpSession", (), {"region_name": kwargs["location"]} - )(), - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="gcp-projects", - provider="gcp", - mode="projects", - include=None, - tasks=[], - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - assert [result.id for result in engine_result.target_results[0].entities] == [ - "project-a", - "project-b", - ] - assert project_calls == [{"credentials_path": None, "quota_project_id": None}] + assert seen["metadata"] == {"team": "security"} -def test_non_aws_fail_fast_cancels_pending_execution_targets(monkeypatch): - execution_started = threading.Event() - calls: list[str] = [] +def test_prepare_target_carries_provider_preflight_and_execution_controls(monkeypatch): + provider = _Provider() + _patch_provider(monkeypatch, provider) + target = _target(max_parallel_regions=3) - def fake_execute_provider_execution_target(**kwargs): - execution_target = kwargs["execution_target"] - calls.append(execution_target.id) - if execution_target.id == "first": - execution_started.set() - return EntityResult( - id="first", - name="first", - type="account", - status=ExecutionStatus.ERROR, - started_at="start", - ended_at="end", - duration_seconds=0.0, - tasks=[], - error="failed", - ) - raise AssertionError("pending provider targets should be cancelled") - - monkeypatch.setattr( - "anvil.runner._execute_provider_execution_target", - fake_execute_provider_execution_target, - ) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="azure-subscriptions", - provider="azure", - mode="subscriptions", - include=["first", "second"], - tasks=[], - fail_fast=True, - max_workers=1, - ) - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, + prepared = prepare_target( + index=0, + target=target, + cli_dry_run=True, cli_include=None, cli_exclude=None, + preparation_cache=_SingleFlightCache(), + auth_cache=AuthCheckCache(), ) - assert execution_started.is_set() - assert calls == ["first"] - assert engine_result.target_results[0].entities[0].status.is_error + assert prepared.provider is provider + assert prepared.provider_preflight is provider.preparation + assert prepared.exclusive_execution_keys == (("test", "target-a"),) + assert prepared.context is not None + assert prepared.context.max_parallel_regions == 3 + assert prepared.context.dry_run is True -@pytest.mark.parametrize( - ("provider", "mode", "include"), - [ - ("aws", "accounts", "111111111111"), - ("azure", "subscriptions", "00000000-0000-0000-0000-000000000000"), - ("gcp", "projects", "project-a"), - ], -) -def test_explicit_modes_reject_cli_exclude_before_execution( - monkeypatch, provider, mode, include +def test_prepare_target_reports_provider_filter_errors_as_config_auth_results( + monkeypatch, ): - executed = False - - def fail_execute_provider_execution_target(**kwargs): - nonlocal executed - executed = True - raise AssertionError("explicit-mode exclude should stop before execution") - - 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._execute_provider_execution_target", - fail_execute_provider_execution_target, - ) - - target = _load_targets( - { - "schema_version": 2, - "targets": [ - { - "name": f"{provider}-{mode}", - "provider": {"name": provider, "mode": mode, "options": {}}, - "regions": [ - "us-east-1" - if provider == "aws" - else "eastus" - if provider == "azure" - else "global" - ], - "include": [include], - "tasks": [{"name": "noop"}], - } - ], - } - )[0] - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=[include], - ) + provider = _Provider() + _patch_provider(monkeypatch, provider) - assert not executed - assert engine_result.auth_results[0].status is ExecutionStatus.ERROR - assert "does not allow exclude" in (engine_result.auth_results[0].message or "") - - -def test_discovery_modes_reject_effective_include_and_exclude(monkeypatch): - 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", - ), - ) - - target = _load_targets( - { - "schema_version": 2, - "targets": [ - { - "name": "aws-org", - "provider": { - "name": "aws", - "mode": "organization", - "options": {"profile": "shared"}, - }, - "regions": ["us-east-1"], - "include": ["111111111111"], - "tasks": [{"name": "noop"}], - } - ], - } - )[0] - - engine_result = run_multiple_targets( - targets=[target], - max_parallel_targets=1, + prepared = prepare_target( + index=0, + target=_target(include=["a"]), cli_dry_run=None, cli_include=None, - cli_exclude=["222222222222"], - ) - - assert engine_result.auth_results[0].status is ExecutionStatus.ERROR - assert "include and exclude together" in ( - engine_result.auth_results[0].message or "" - ) - - -def test_run_multiple_targets_reuses_same_profile_auth_during_preparation(monkeypatch): - auth_check_calls: list[str] = [] - - monkeypatch.setattr( - "anvil.runner.infer_auth_source", lambda profile: AuthSource.PROFILE_STATIC + cli_exclude=["b"], + preparation_cache=_SingleFlightCache(), + auth_cache=AuthCheckCache(), ) - def fake_auth_check(**kwargs): - auth_check_calls.append(kwargs["target_name"]) - return AuthResult( - target_name=kwargs["target_name"], - status=ExecutionStatus.SUCCESS, - source=kwargs["auth_source"].value, - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ) - - monkeypatch.setattr("anvil.runner.auth_check", fake_auth_check) - monkeypatch.setattr("anvil.runner.resolve_tasks", _empty_resolved_execution) - - def fake_preflight_execution(self, **kwargs): - data = SimpleNamespace( - session_factory=kwargs["session_factory"], - base_session=object(), - organization_id="o-shared", - management_account_id="999999999999", - base_session_account_id="999999999999", - discovered_accounts={ - "999999999999": { - "account_number": "999999999999", - "account_alias": "management", - } - }, - region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, - ) - return SimpleNamespace(data=data, exclusive_execution_key="o-shared") - - monkeypatch.setattr( - "anvil.runner.AwsProvider.preflight_execution", fake_preflight_execution - ) - monkeypatch.setattr( - "anvil.runner._execute_provider_targets", - lambda **kwargs: TargetResult.create( - config_branch=kwargs["target"].config_branch, - target_name=kwargs["target"].name, - dry_run=kwargs["context"].dry_run, - entities=[], - ), - ) + assert prepared.context is None + assert prepared.auth_result.status is ExecutionStatus.ERROR + assert prepared.auth_result.source == "config" + assert "mutually exclusive" in str(prepared.auth_result.message) - targets = [ - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", profile="shared", tasks=[] - ), - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-b", profile="shared", tasks=[] - ), - ] - engine_result = run_multiple_targets( - targets=targets, - max_parallel_targets=1, +def test_run_prepared_target_passes_opaque_preflight_to_provider(monkeypatch): + provider = _Provider() + _patch_provider(monkeypatch, provider) + prepared = prepare_target( + index=0, + target=_target(), cli_dry_run=None, cli_include=None, cli_exclude=None, + preparation_cache=_SingleFlightCache(), + auth_cache=AuthCheckCache(), ) - assert auth_check_calls == ["org-a"] - assert [result.target_name for result in engine_result.auth_results] == [ - "org-a", - "org-b", - ] + outcome = run_prepared_target(prepared_target=prepared) + assert outcome.target_result.error is None + assert provider.seen_preparation is provider.preparation -def test_prepare_target_reuses_same_org_discovery_cache(monkeypatch): - discovered_accounts = { - "111111111111": {"account_number": "111111111111", "account_alias": "acct-a"} - } - region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT", "us-west-2": "ENABLED"} - call_counts = {"accounts": 0, "regions": 0} - 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", +def test_run_prepared_target_converts_provider_resolution_errors(): + provider = _Provider(fail_resolution=True) + target = _target() + prepared = PreparedTarget( + index=0, + provider=provider, + effective_target=target, + auth_result=SimpleNamespace(status=ExecutionStatus.SUCCESS), + context=ExecutionContext( + regions=["global"], dry_run=False, tasks=[], metadata={} ), ) - monkeypatch.setattr("anvil.runner.resolve_tasks", _empty_resolved_execution) - class FakeSessionFactory: - def create_base_session(self, **kwargs): - return type("_BaseSession", (), {"profile_name": kwargs["profile_name"]})() + outcome = run_prepared_target(prepared_target=prepared) - monkeypatch.setattr("anvil.runner.SessionFactory", FakeSessionFactory) - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.describe_organization", - staticmethod(lambda session: ("o-shared", "999999999999")), - ) - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.describe_base_session_account", - staticmethod(lambda session: "999999999999"), - ) - - def fake_discover_accounts(session): - call_counts["accounts"] += 1 - return discovered_accounts - - def fake_discover_regions(session): - call_counts["regions"] += 1 - return region_statuses - - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.discover_accounts", - staticmethod(fake_discover_accounts), - ) - monkeypatch.setattr( - "anvil.providers.aws.provider.AwsRegionService.discover_region_statuses", - staticmethod(fake_discover_regions), - ) + assert outcome.target_result.error == "resolution failed" + assert outcome.target_result.entities == [] - organization_cache = OrganizationRunCache() - auth_cache = AuthCheckCache() - target_a = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - profile="shared", - regions=["us-east-1"], - tasks=[], - ) - target_b = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-b", - profile="shared", - regions=["us-west-2"], - tasks=[], - ) - prepared_a = prepare_target( +def test_scheduler_skips_targets_with_overlapping_provider_keys(): + target = _target() + context = ExecutionContext(regions=["global"], dry_run=False, tasks=[], metadata={}) + blocked = PreparedTarget( index=0, - target=target_a, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - organization_cache=organization_cache, - auth_cache=auth_cache, + provider=_Provider(), + effective_target=target, + auth_result=SimpleNamespace(), + context=context, + exclusive_execution_keys=(("test", "shared"),), ) - prepared_b = prepare_target( + eligible = PreparedTarget( index=1, - target=target_b, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - organization_cache=organization_cache, - auth_cache=auth_cache, - ) - - assert call_counts == {"accounts": 1, "regions": 1} - assert prepared_a.base_session is not None - 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.region_statuses == region_statuses - assert prepared_b.region_statuses == region_statuses - - -def test_run_multiple_targets_reuses_same_org_discovery_cache(monkeypatch): - discovered_accounts = { - "111111111111": {"account_number": "111111111111", "account_alias": "acct-a"} - } - region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT", "us-west-2": "ENABLED"} - call_counts = {"accounts": 0, "regions": 0, "execute": 0} - - 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", _empty_resolved_execution) - - class FakeSessionFactory: - def create_base_session(self, **kwargs): - 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.describe_base_session_account", - staticmethod(lambda session: "999999999999"), - ) - - def fake_discover_accounts(session): - call_counts["accounts"] += 1 - return discovered_accounts - - def fake_discover_regions(session): - call_counts["regions"] += 1 - return region_statuses - - def fake_execute_provider_targets(**kwargs): - call_counts["execute"] += 1 - return TargetResult.create( - config_branch=kwargs["target"].config_branch, - target_name=kwargs["target"].name, - dry_run=kwargs["context"].dry_run, - entities=[], - ) - - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.discover_accounts", - staticmethod(fake_discover_accounts), - ) - monkeypatch.setattr( - "anvil.providers.aws.provider.AwsRegionService.discover_region_statuses", - staticmethod(fake_discover_regions), - ) - monkeypatch.setattr( - "anvil.runner._execute_provider_targets", fake_execute_provider_targets - ) - - targets = _load_targets( - { - "schema_version": 2, - "max_parallel_targets": 2, - "targets": [ - { - "name": "org-a", - "provider": { - "name": "aws", - "mode": "organization", - "options": {"profile": "shared"}, - }, - "regions": ["us-east-1"], - "tasks": [{"name": "noop"}], - }, - { - "name": "org-b", - "provider": { - "name": "aws", - "mode": "organization", - "options": {"profile": "shared"}, - }, - "regions": ["us-west-2"], - "tasks": [{"name": "noop"}], - }, - ], - } - ) - - engine_result = run_multiple_targets( - targets=targets, - max_parallel_targets=2, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - ) - - assert call_counts == {"accounts": 1, "regions": 1, "execute": 2} - assert [result.target_name for result in engine_result.target_results] == [ - "org-a", - "org-b", - ] - - -def test_run_multiple_targets_preserves_aws_account_access_strategies(monkeypatch): - observed_accounts: dict[str, list[tuple[str, 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", _empty_resolved_execution) - - class FakeSessionFactory: - def create_base_session(self, **kwargs): - return object() - - def fake_execute_provider_targets(**kwargs): - observed_accounts[kwargs["target"].name] = [ - (execution_target.id, execution_target.provider_data.access_strategy.value) - for execution_target in kwargs["execution_targets"] - ] - return TargetResult.create( - config_branch=kwargs["target"].config_branch, - target_name=kwargs["target"].name, - dry_run=kwargs["context"].dry_run, - entities=[], - ) - - monkeypatch.setattr("anvil.runner.SessionFactory", FakeSessionFactory) - monkeypatch.setattr( - "anvil.runner._execute_provider_targets", fake_execute_provider_targets - ) - - targets = _load_targets( - { - "schema_version": 2, - "targets": [ - { - "name": "direct-account", - "provider": {"name": "aws", "mode": "accounts", "options": {}}, - "regions": ["us-east-1"], - "include": ["111111111111"], - "tasks": [{"name": "noop"}], - }, - { - "name": "assume-role-accounts", - "provider": { - "name": "aws", - "mode": "accounts", - "options": {"role_name": "AuditRole"}, - }, - "regions": ["us-east-1"], - "include": ["222222222222", "333333333333"], - "tasks": [{"name": "noop"}], - }, - ], - } + provider=_Provider(), + effective_target=_target(name="target-b"), + auth_result=SimpleNamespace(), + context=context, + exclusive_execution_keys=(("test", "other"),), ) + pending = deque([blocked, eligible]) - run_multiple_targets( - targets=targets, - max_parallel_targets=1, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, + selected = _next_eligible_target( + pending=pending, active_execution_keys={("test", "shared")} ) - assert observed_accounts["direct-account"] == [("111111111111", "direct_profile")] - assert observed_accounts["assume-role-accounts"] == [ - ("222222222222", "assume_role"), - ("333333333333", "assume_role"), - ] + assert selected is eligible + assert list(pending) == [blocked] -def test_prepare_target_keeps_base_session_account_out_of_org_cache(monkeypatch): - discovered_accounts = { - "111111111111": {"account_number": "111111111111", "account_alias": "payer"}, - "222222222222": { - "account_number": "222222222222", - "account_alias": "delegated-admin", - }, - } - region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT"} - call_counts = {"accounts": 0, "regions": 0} - base_account_ids = { - "management-profile": "111111111111", - "delegated-profile": "222222222222", - } +def test_single_flight_cache_shares_concurrent_work(): + cache = _SingleFlightCache() + started = threading.Event() + release = threading.Event() + calls = 0 + results = [] - 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", _empty_resolved_execution) - - class FakeSessionFactory: - def create_base_session(self, **kwargs): - 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", "111111111111")), - ) - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.describe_base_session_account", - staticmethod(lambda session: base_account_ids[session.profile_name]), - ) + def create(): + nonlocal calls + calls += 1 + started.set() + assert release.wait(timeout=1.0) + return "value" - def fake_discover_accounts(session): - call_counts["accounts"] += 1 - return discovered_accounts + def lookup(): + results.append(cache.get_or_create(key="shared", create=create)) - def fake_discover_regions(session): - call_counts["regions"] += 1 - return region_statuses + threads = [threading.Thread(target=lookup) for _ in range(2)] + for thread in threads: + thread.start() + assert started.wait(timeout=1.0) + release.set() + for thread in threads: + thread.join(timeout=1.0) - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.discover_accounts", - staticmethod(fake_discover_accounts), - ) - monkeypatch.setattr( - "anvil.providers.aws.provider.AwsRegionService.discover_region_statuses", - staticmethod(fake_discover_regions), - ) + assert calls == 1 + assert sorted(results) == [("value", False, False), ("value", True, True)] - organization_cache = OrganizationRunCache() - auth_cache = AuthCheckCache() - prepared_management = prepare_target( - index=0, - target=TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="management-auth", - profile="management-profile", - tasks=[], - ), - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - organization_cache=organization_cache, - auth_cache=auth_cache, - ) - prepared_delegated = prepare_target( - index=1, - target=TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="delegated-auth", - profile="delegated-profile", - tasks=[], - ), - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - organization_cache=organization_cache, - auth_cache=auth_cache, - ) +def test_single_flight_cache_releases_waiters_after_error(): + cache = _SingleFlightCache() + started = threading.Event() + release = threading.Event() + errors = [] - assert call_counts == {"accounts": 1, "regions": 1} - assert prepared_management.management_account_id == "111111111111" - assert prepared_management.base_session_account_id == "111111111111" - assert prepared_delegated.management_account_id == "111111111111" - assert prepared_delegated.base_session_account_id == "222222222222" - assert prepared_management.discovered_accounts == discovered_accounts - assert prepared_delegated.discovered_accounts == discovered_accounts + def create(): + started.set() + assert release.wait(timeout=1.0) + raise ValueError("discovery failed") + def lookup(): + try: + cache.get_or_create(key="shared", create=create) + except ValueError as error: + errors.append(str(error)) -def test_prepare_target_uses_bootstrap_region_for_region_selector(monkeypatch): - created_session_regions: list[str] = [] + threads = [threading.Thread(target=lookup) for _ in range(2)] + for thread in threads: + thread.start() + assert started.wait(timeout=1.0) + release.set() + for thread in threads: + thread.join(timeout=1.0) - 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", _empty_resolved_execution) + assert errors == ["discovery failed", "discovery failed"] - 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.describe_base_session_account", - staticmethod(lambda session: "999999999999"), - ) - monkeypatch.setattr( - "anvil.runner.OrganizationResolver.discover_accounts", - staticmethod(lambda session: {}), - ) - monkeypatch.setattr( - "anvil.providers.aws.provider.AwsRegionService.discover_region_statuses", - staticmethod(lambda session: {"us-east-1": "ENABLED_BY_DEFAULT"}), - ) +def test_fail_fast_does_not_start_pending_execution_targets(monkeypatch): + calls = [] - prepare_target( - index=0, - target=TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - profile="shared", - regions=["all"], + def execute(**kwargs): + execution_target = kwargs["execution_target"] + calls.append(execution_target.id) + if execution_target.id != "first": + raise AssertionError("pending target started after fail-fast") + return EntityResult( + id="first", + name="first", + type="resource", + provider="test", + metadata={}, + status=ExecutionStatus.ERROR, + started_at="2026-01-01T00:00:00+00:00", + ended_at="2026-01-01T00:00:01+00:00", + duration_seconds=1.0, 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(): - entry = OrganizationRunCacheEntry( - management_account_id="999999999999", - discovered_accounts={ - "111111111111": { - "account_number": "111111111111", - "account_alias": "acct-a", - } - }, - region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, - ) - cache = OrganizationRunCache() - discovery_started = threading.Event() - waiter_started = threading.Event() - release_discovery = threading.Event() - discover_calls = 0 - discover_lock = threading.Lock() - - def discover(): - nonlocal discover_calls - with discover_lock: - discover_calls += 1 - - discovery_started.set() - assert waiter_started.wait(timeout=1) - assert release_discovery.wait(timeout=1) - return entry - - def owner_lookup(): - return cache.get_or_discover(organization_id="o-shared", discover=discover) - - def waiter_lookup(): - waiter_started.set() - return cache.get_or_discover(organization_id="o-shared", discover=discover) - - with ThreadPoolExecutor(max_workers=2) as executor: - first = executor.submit(owner_lookup) - assert discovery_started.wait(timeout=1) - - second = executor.submit(waiter_lookup) - assert waiter_started.wait(timeout=1) - release_discovery.set() - - first_lookup = first.result(timeout=1) - second_lookup = second.result(timeout=1) - - assert discover_calls == 1 - assert first_lookup.entry is entry - assert first_lookup.hit is False - assert first_lookup.waited is False - assert second_lookup.entry is entry - assert second_lookup.hit is True - assert second_lookup.waited is True - - -def test_organization_run_cache_releases_waiters_after_discovery_error(): - cache = OrganizationRunCache() - discovery_started = threading.Event() - waiter_started = threading.Event() - release_discovery = threading.Event() - - def fail_discovery(): - discovery_started.set() - assert waiter_started.wait(timeout=1) - assert release_discovery.wait(timeout=1) - raise RuntimeError("discovery failed") - - def owner_lookup(): - return cache.get_or_discover( - organization_id="o-shared", discover=fail_discovery + error="failed", ) - def waiter_lookup(): - waiter_started.set() - return cache.get_or_discover( - organization_id="o-shared", discover=fail_discovery - ) - - with ThreadPoolExecutor(max_workers=2) as executor: - first = executor.submit(owner_lookup) - assert discovery_started.wait(timeout=1) - - second = executor.submit(waiter_lookup) - assert waiter_started.wait(timeout=1) - release_discovery.set() - - with pytest.raises(RuntimeError, match="discovery failed"): - second.result(timeout=1) - with pytest.raises(RuntimeError, match="discovery failed"): - first.result(timeout=1) - - entry = OrganizationRunCacheEntry( - management_account_id="999999999999", - discovered_accounts={}, - region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, - ) - retry_lookup = cache.get_or_discover( - organization_id="o-shared", discover=lambda: entry - ) - - assert retry_lookup.entry is entry - assert retry_lookup.hit is False - assert retry_lookup.waited is False - - -def test_run_prepared_target_uses_cached_org_preflight(monkeypatch): - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - profile="shared", - regions=["us-east-1"], - tasks=[], - role_name="TestRole", - ) - context = ExecutionContext( - regions=["us-east-1"], - role_name="TestRole", - dry_run=False, - tasks=[], - metadata={}, - ) - base_session = type("_BaseSession", (), {"profile_name": "shared"})() - discovered_accounts = { - "111111111111": {"account_number": "111111111111", "account_alias": "acct-a"} - } - region_statuses = {"us-east-1": "ENABLED_BY_DEFAULT"} - - class FakeSessionFactory: - def create_base_session(self, **kwargs): - raise AssertionError("execution should reuse the preflight base session") - - monkeypatch.setattr( - "anvil.organization.OrganizationResolver.describe_organization", - staticmethod( - lambda session: (_ for _ in ()).throw( - AssertionError("execution should not rediscover organization identity") - ) - ), - ) - monkeypatch.setattr( - "anvil.organization.OrganizationResolver.discover_accounts", - staticmethod( - lambda session: (_ for _ in ()).throw( - AssertionError("execution should not rediscover accounts") - ) - ), - ) - monkeypatch.setattr( - "anvil.organization.AwsRegionService.discover_region_statuses", - staticmethod( - lambda session: (_ for _ in ()).throw( - AssertionError("execution should not rediscover region statuses") - ) - ), - ) - - monkeypatch.setattr( - "anvil.runner._execute_provider_targets", - lambda **kwargs: TargetResult.create( - config_branch=kwargs["target"].config_branch, - target_name=kwargs["target"].name, - dry_run=kwargs["context"].dry_run, - entities=[], - ), - ) - - prepared_target = PreparedTarget( - index=0, - effective_target=target, - auth_result=AuthResult( - target_name=target.name, - status=ExecutionStatus.SUCCESS, - source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=context, - provider_preflight=SimpleNamespace( - session_factory=FakeSessionFactory(), - base_session=base_session, - organization_id="o-shared", - management_account_id="999999999999", - base_session_account_id="999999999999", - discovered_accounts=discovered_accounts, - region_statuses=region_statuses, - ), - exclusive_execution_key="o-shared", - session_factory=FakeSessionFactory(), - base_session=base_session, - organization_id="o-shared", - management_account_id="999999999999", - base_session_account_id="999999999999", - discovered_accounts=discovered_accounts, - region_statuses=region_statuses, - ) - - outcome = run_prepared_target(prepared_target=prepared_target) - - assert outcome.target_result.target_name == "org-a" - assert outcome.cancelled is False - - -def test_run_prepared_target_converts_aws_value_error(monkeypatch): - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="group-a", include=["111111111111"] - ) + monkeypatch.setattr("anvil.runner._execute_provider_execution_target", execute) + target = _target(fail_fast=True, max_workers=1) context = ExecutionContext( - regions=["us-east-1"], role_name=None, dry_run=False, tasks=[], metadata={} - ) - - monkeypatch.setattr( - "anvil.runner.AwsProvider.resolve_execution_targets", - lambda self, **kwargs: (_ for _ in ()).throw(ValueError("bad aws config")), + regions=["global"], dry_run=False, tasks=[], metadata={}, fail_fast=True ) - prepared_target = PreparedTarget( - index=0, - effective_target=target, - auth_result=AuthResult( - target_name=target.name, - status=ExecutionStatus.SUCCESS, - source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=context, - ) - - outcome = run_prepared_target(prepared_target=prepared_target) - - assert outcome.target_result.error == "bad aws config" - assert outcome.target_result.entities == [] - - -def test_run_prepared_target_does_not_swallow_unexpected_aws_exception(monkeypatch): - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="group-a", include=["111111111111"] - ) - context = ExecutionContext( - regions=["us-east-1"], role_name=None, dry_run=False, tasks=[], metadata={} - ) - - monkeypatch.setattr( - "anvil.runner.AwsProvider.resolve_execution_targets", - lambda self, **kwargs: (_ for _ in ()).throw( - RuntimeError("unexpected aws failure") - ), - ) - - prepared_target = PreparedTarget( - index=0, - effective_target=target, - auth_result=AuthResult( - target_name=target.name, - status=ExecutionStatus.SUCCESS, - source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - context=context, - ) - - with pytest.raises(RuntimeError, match="unexpected aws failure"): - run_prepared_target(prepared_target=prepared_target) - - -def test_prepare_target_carries_max_parallel_regions_into_context(monkeypatch): - 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", _empty_resolved_execution) - - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="group-a", - include=["111111111111"], - max_parallel_regions=3, - ) - - prepared = prepare_target( - index=0, + result = _execute_provider_targets( + provider=_Provider(), target=target, - cli_dry_run=None, - cli_include=None, - cli_exclude=None, - organization_cache=OrganizationRunCache(), - auth_cache=AuthCheckCache(), + context=context, + execution_targets=[ + ExecutionTarget( + id="first", + name="first", + type="resource", + provider="test", + regions=["global"], + ), + ExecutionTarget( + id="second", + name="second", + type="resource", + provider="test", + regions=["global"], + ), + ], + benchmark_data=None, ) - assert prepared.context is not None - assert prepared.context.max_parallel_regions == 3 + assert calls == ["first"] + assert result.entities[0].status is ExecutionStatus.ERROR diff --git a/tests/runner/test_session_factory.py b/tests/runner/test_session_factory.py index c28131f..dc434e4 100644 --- a/tests/runner/test_session_factory.py +++ b/tests/runner/test_session_factory.py @@ -5,8 +5,8 @@ import pytest from botocore.exceptions import ClientError -from anvil import session as session_module -from anvil.session import AssumedRoleCredentials, SessionFactory +from anvil.providers.aws import session as session_module +from anvil.providers.aws.session import AssumedRoleCredentials, SessionFactory class FakeBotoSession: diff --git a/tests/tasks/test_provider_task_loader.py b/tests/tasks/test_provider_task_loader.py index 9f94ac3..a892e17 100644 --- a/tests/tasks/test_provider_task_loader.py +++ b/tests/tasks/test_provider_task_loader.py @@ -5,8 +5,11 @@ import pytest +from anvil._components import ComponentOrigin, ComponentSource from anvil.task_loader import TaskConfigError, TaskDescriptor +SUPPORTED_TASK_SCOPES = frozenset({"region", "target"}) + def _run_for(name: str): def run(**kwargs): @@ -17,7 +20,14 @@ def run(**kwargs): def _descriptor(name: str, source: str) -> TaskDescriptor: return TaskDescriptor( - name=name, load=lambda: _run_for(f"{source}:{name}"), source=source + name=name, + load=lambda: _run_for(f"{source}:{name}"), + source=ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label=source, + provider=None if source == "universal" else source, + ), ) @@ -33,7 +43,7 @@ def test_real_aws_descriptor_index_includes_moved_aws_tasks(): index = task_loader.provider_task_descriptor_index(provider_name="aws") assert "count_vpc" in index - assert [descriptor.source for descriptor in index["count_vpc"]] == ["aws"] + assert [str(descriptor.source) for descriptor in index["count_vpc"]] == ["aws"] def test_real_aws_tasks_use_provider_neutral_signature(): @@ -55,7 +65,7 @@ def test_real_aws_tasks_use_provider_neutral_signature(): for descriptors in index.values(): for descriptor in descriptors: - if descriptor.source != "aws": + if str(descriptor.source) != "aws": continue parameters = set(inspect.signature(descriptor.load()).parameters) @@ -75,7 +85,7 @@ def test_real_azure_descriptor_index_includes_azure_tasks_only_for_azure(): assert "count_resource_groups" in azure_index assert [ - descriptor.source for descriptor in azure_index["count_resource_groups"] + str(descriptor.source) for descriptor in azure_index["count_resource_groups"] ] == ["azure"] assert "count_resource_groups" not in aws_index assert "count_resource_groups" not in gcp_index @@ -92,7 +102,7 @@ def test_real_gcp_descriptor_index_includes_gcp_tasks_only_for_gcp(): github_index = task_loader.provider_task_descriptor_index(provider_name="github") assert "get_project_info" in gcp_index - assert [descriptor.source for descriptor in gcp_index["get_project_info"]] == [ + assert [str(descriptor.source) for descriptor in gcp_index["get_project_info"]] == [ "gcp" ] assert "get_project_info" not in aws_index @@ -106,7 +116,9 @@ def test_universal_noop_resolves_for_all_providers(): for provider_name in ("aws", "azure", "gcp", "github"): execution = task_loader.resolve_tasks( - task_specs=[{"name": "noop"}], provider_name=provider_name + task_specs=[{"name": "noop"}], + provider_name=provider_name, + supported_task_scopes=SUPPORTED_TASK_SCOPES, ) assert execution.ordered[0].name == "noop" assert execution.ordered[0].run.__module__ in {"anvil.providers.tasks.noop"} @@ -119,7 +131,9 @@ def test_aws_only_tasks_do_not_resolve_for_azure_or_gcp(): for provider_name in ("azure", "gcp", "github"): with pytest.raises(TaskConfigError, match="count_vpc"): task_loader.resolve_tasks( - task_specs=[{"name": "count_vpc"}], provider_name=provider_name + task_specs=[{"name": "count_vpc"}], + provider_name=provider_name, + supported_task_scopes=SUPPORTED_TASK_SCOPES, ) @@ -137,12 +151,32 @@ def test_duplicate_universal_and_provider_task_name_is_ambiguous(monkeypatch): ) }, ) + monkeypatch.setattr( + task_loader, + "_provider_task_discovery", + lambda provider_name: ( + { + "shared": ( + _descriptor("shared", "universal"), + _descriptor("shared", provider_name), + ) + }, + (), + ), + ) index = task_loader.provider_task_descriptor_index(provider_name="aws") - assert [descriptor.source for descriptor in index["shared"]] == ["universal", "aws"] + assert [str(descriptor.source) for descriptor in index["shared"]] == [ + "universal", + "aws", + ] with pytest.raises(TaskConfigError, match="ambiguous.*universal.*aws"): - task_loader.resolve_tasks(task_specs=[{"name": "shared"}], provider_name="aws") + task_loader.resolve_tasks( + task_specs=[{"name": "shared"}], + provider_name="aws", + supported_task_scopes=SUPPORTED_TASK_SCOPES, + ) def test_provider_descriptor_index_adds_provider_package_tasks(monkeypatch): @@ -160,7 +194,9 @@ def fake_package_descriptors(*, package_name, source): _clear_task_loader_caches(task_loader) execution = task_loader.resolve_tasks( - task_specs=[{"name": "aws_only"}], provider_name="example" + task_specs=[{"name": "aws_only"}], + provider_name="example", + supported_task_scopes=SUPPORTED_TASK_SCOPES, ) assert execution.ordered[0].name == "aws_only" @@ -182,7 +218,9 @@ def fake_package_descriptors(*, package_name, source): _clear_task_loader_caches(task_loader) execution = task_loader.resolve_tasks( - task_specs=[{"name": "shared_task"}], provider_name="future" + task_specs=[{"name": "shared_task"}], + provider_name="future", + supported_task_scopes=SUPPORTED_TASK_SCOPES, ) assert execution.ordered[0].run() == "universal:shared_task" @@ -217,6 +255,7 @@ def fake_package_descriptors(*, package_name, source): {"name": "gamma", "depends_on": ["beta"]}, ], provider_name="build_once", + supported_task_scopes=SUPPORTED_TASK_SCOPES, ) assert [task.name for task in execution.ordered] == ["alpha", "beta", "gamma"] diff --git a/tests/tasks/test_task_loader.py b/tests/tasks/test_task_loader.py index 1a644b5..486ff3d 100644 --- a/tests/tasks/test_task_loader.py +++ b/tests/tasks/test_task_loader.py @@ -2,6 +2,7 @@ import sys from types import ModuleType +from anvil._components import ComponentDescriptor, ComponentOrigin, ComponentSource from anvil.task_loader import ( TaskConfigError, TaskDescriptor, @@ -17,7 +18,24 @@ def _run(**kwargs): def _descriptor(name: str) -> TaskDescriptor: - return TaskDescriptor(name=name, load=lambda: _run, source="aws") + return TaskDescriptor( + name=name, + load=lambda: _run, + source=ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label="aws", + provider="aws", + ), + ) + + +def _resolve(task_specs): + return resolve_tasks( + task_specs=task_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) def _mock_provider_tasks(monkeypatch, names: list[str]) -> None: @@ -29,6 +47,10 @@ def fake_index(provider_name: str): "anvil.task_loader._provider_task_descriptor_index", lambda provider_name: {name: (_descriptor(name),) for name in names}, ) + monkeypatch.setattr( + "anvil.task_loader._provider_task_discovery", + lambda provider_name: ({name: [_descriptor(name)] for name in names}, ()), + ) resolve_tasks.__globals__["_resolve_tasks_cached"].cache_clear() resolve_tasks.__globals__["_load_provider_task_callable"].cache_clear() @@ -59,7 +81,7 @@ def run(**kwargs): def test_resolve_tasks_no_dependencies(monkeypatch): _mock_provider_tasks(monkeypatch, ["a", "b"]) - execution = resolve_tasks(task_specs=[{"name": "a"}, {"name": "b"}]) + execution = _resolve([{"name": "a"}, {"name": "b"}]) assert [task.name for task in execution.ordered] == ["a", "b"] assert all(task.scope is TaskScope.REGION for task in execution.ordered) @@ -87,6 +109,7 @@ def test_resolve_tasks_rejects_invalid_task_scope(monkeypatch): with pytest.raises(TaskConfigError, match="invalid TASK_SCOPE"): resolve_tasks( task_specs=[{"name": "bad"}], + provider_name="azure", supported_task_scopes=frozenset({"region", "target"}), ) @@ -134,9 +157,7 @@ def test_target_task_cannot_depend_on_region_task(monkeypatch): def test_resolve_tasks_dependency_order(monkeypatch): _mock_provider_tasks(monkeypatch, ["a", "b"]) - execution = resolve_tasks( - task_specs=[{"name": "b", "depends_on": ["a"]}, {"name": "a"}] - ) + execution = _resolve([{"name": "b", "depends_on": ["a"]}, {"name": "a"}]) assert [task.name for task in execution.ordered] == ["a", "b"] @@ -145,11 +166,8 @@ def test_resolve_tasks_cycle(monkeypatch): _mock_provider_tasks(monkeypatch, ["a", "b"]) with pytest.raises(TaskConfigError): - resolve_tasks( - task_specs=[ - {"name": "a", "depends_on": ["b"]}, - {"name": "b", "depends_on": ["a"]}, - ] + _resolve( + [{"name": "a", "depends_on": ["b"]}, {"name": "b", "depends_on": ["a"]}] ) @@ -157,12 +175,12 @@ def test_resolve_tasks_rejects_duplicate_configured_task_names(monkeypatch): _mock_provider_tasks(monkeypatch, ["a"]) with pytest.raises(TaskConfigError, match="Duplicate task name detected: 'a'"): - resolve_tasks(task_specs=[{"name": "a"}, {"name": "a"}]) + _resolve([{"name": "a"}, {"name": "a"}]) def test_resolve_tasks_reports_missing_task_usefully(): with pytest.raises(TaskConfigError) as exc_info: - resolve_tasks(task_specs=[{"name": "missing_task_for_test"}]) + _resolve([{"name": "missing_task_for_test"}]) error = str(exc_info.value) assert "missing_task_for_test" in error @@ -187,11 +205,13 @@ def test_list_tasks_includes_provider_tasks(): tasks = list_tasks() assert isinstance(tasks, list) - assert all(isinstance(task, TaskDescriptor) for task in tasks) + assert all(isinstance(task, ComponentDescriptor) for task in tasks) - assert any(task.name == "noop" and task.source == "universal" for task in tasks) assert any( - task.name == "remove_iam_user" and task.source == "aws" for task in tasks + task.name == "noop" and str(task.source) == "universal" for task in tasks + ) + assert any( + task.name == "remove_iam_user" and str(task.source) == "aws" for task in tasks ) @@ -202,7 +222,7 @@ def test_list_tasks_sorted_by_source_then_name(): tasks = list_tasks() - pairs = [(task.source, task.name) for task in tasks] + pairs = [(str(task.source), task.name) for task in tasks] assert pairs == sorted(pairs) @@ -217,5 +237,5 @@ def test_discover_tasks_includes_provider_tasks(): assert "noop" in names noop = next(task for task in tasks if task.name == "noop") - assert noop.source == "universal" + assert str(noop.source) == "universal" assert callable(noop.load) diff --git a/tests/tasks/test_task_loader_cache.py b/tests/tasks/test_task_loader_cache.py index 8e32e67..3f14078 100644 --- a/tests/tasks/test_task_loader_cache.py +++ b/tests/tasks/test_task_loader_cache.py @@ -2,6 +2,7 @@ import importlib +from anvil._components import ComponentOrigin, ComponentSource from anvil.task_loader import TaskDescriptor @@ -15,7 +16,16 @@ def run(**kwargs): return run - return TaskDescriptor(name=task_name, load=load, source="aws") + return TaskDescriptor( + name=task_name, + load=load, + source=ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label="aws", + provider="aws", + ), + ) monkeypatch.setattr( task_loader, @@ -25,6 +35,14 @@ def run(**kwargs): "beta": (descriptor("beta"),), }, ) + monkeypatch.setattr( + task_loader, + "_provider_task_discovery", + lambda provider_name: ( + {"alpha": [descriptor("alpha")], "beta": [descriptor("beta")]}, + (), + ), + ) task_loader._load_provider_task_callable.cache_clear() task_loader._resolve_tasks_cached.cache_clear() @@ -39,8 +57,16 @@ def test_repeated_identical_task_specs_reuse_cached_resolution(monkeypatch): task_specs = [{"name": "alpha"}, {"name": "beta", "depends_on": ["alpha"]}] - first = task_loader.resolve_tasks(task_specs=task_specs) - second = task_loader.resolve_tasks(task_specs=task_specs) + first = task_loader.resolve_tasks( + task_specs=task_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) + second = task_loader.resolve_tasks( + task_specs=task_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) assert load_calls == ["alpha", "beta"] assert [task.name for task in first.ordered] == ["alpha", "beta"] @@ -60,8 +86,16 @@ def test_different_task_order_is_not_treated_as_same_cache_key(monkeypatch): first_specs = [{"name": "alpha"}, {"name": "beta"}] second_specs = [{"name": "beta"}, {"name": "alpha"}] - first = task_loader.resolve_tasks(task_specs=first_specs) - second = task_loader.resolve_tasks(task_specs=second_specs) + first = task_loader.resolve_tasks( + task_specs=first_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) + second = task_loader.resolve_tasks( + task_specs=second_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) assert [task.name for task in first.ordered] == ["alpha", "beta"] assert [task.name for task in second.ordered] == ["beta", "alpha"] @@ -77,12 +111,20 @@ def test_returned_values_do_not_expose_shared_mutable_cached_state(monkeypatch): task_specs = [{"name": "alpha"}, {"name": "beta", "depends_on": ["alpha"]}] - first = task_loader.resolve_tasks(task_specs=task_specs) + first = task_loader.resolve_tasks( + task_specs=task_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) first.ordered[1].depends_on.append("extra") first.adjacency["alpha"].append("extra") first.ordered.append(first.ordered[0]) - second = task_loader.resolve_tasks(task_specs=task_specs) + second = task_loader.resolve_tasks( + task_specs=task_specs, + provider_name="aws", + supported_task_scopes=frozenset({"region"}), + ) assert [task.name for task in second.ordered] == ["alpha", "beta"] assert second.ordered[1].depends_on == ["alpha"] diff --git a/tests/tasks/test_task_loader_cache_integration.py b/tests/tasks/test_task_loader_cache_integration.py index c5e313a..52e6856 100644 --- a/tests/tasks/test_task_loader_cache_integration.py +++ b/tests/tasks/test_task_loader_cache_integration.py @@ -1,8 +1,14 @@ from __future__ import annotations import importlib -from types import SimpleNamespace +from anvil._components import ComponentOrigin, ComponentSource +from anvil.providers.base import ( + ProviderAuthResult, + ProviderExecutionPlan, + ProviderMetadata, + ProviderPreparation, +) from anvil.task_loader import TaskDescriptor @@ -21,13 +27,18 @@ def alpha_run(**kwargs): def beta_run(**kwargs): return "beta" + source = ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label="test", + provider="test", + ) + task_index = { + "alpha": (TaskDescriptor("alpha", source, lambda: alpha_run),), + "beta": (TaskDescriptor("beta", source, lambda: beta_run),), + } monkeypatch.setattr( - task_loader, - "_provider_task_descriptor_index", - lambda provider_name: { - "alpha": (TaskDescriptor("alpha", lambda: alpha_run, "aws"),), - "beta": (TaskDescriptor("beta", lambda: beta_run, "aws"),), - }, + task_loader, "_provider_task_discovery", lambda provider_name: (task_index, ()) ) task_loader._load_provider_task_callable.cache_clear() task_loader._resolve_tasks_cached.cache_clear() @@ -35,62 +46,53 @@ def beta_run(**kwargs): target = descriptors.TargetDescriptor( config_branch=descriptors.ConfigBranch.TARGETS, name="demo-org", + provider="test", + mode="fleet", tasks=[{"name": "alpha"}, {"name": "beta", "depends_on": ["alpha"]}], ) - monkeypatch.setattr( - runner, - "auth_check", - lambda **kwargs: results.AuthResult( - target_name=kwargs["target_name"], - status=results.ExecutionStatus.SUCCESS, - source="test", - started_at="start", - ended_at="end", - duration_seconds=0.0, - message="ok", - ), - ) - monkeypatch.setattr( - runner, "infer_auth_source", lambda profile: SimpleNamespace(value="test") - ) - monkeypatch.setattr( - runner.AwsProvider, - "preflight_execution", - lambda self, **kwargs: SimpleNamespace( - data=SimpleNamespace( - session_factory=kwargs["session_factory"], - base_session=object(), - organization_id="o-example", - management_account_id="123456789012", - base_session_account_id="123456789012", - discovered_accounts={}, - region_statuses={"us-east-1": "ENABLED_BY_DEFAULT"}, - ), - exclusive_execution_key="o-example", - ), - ) + class FakeProvider: + metadata = ProviderMetadata( + name="test", + display_name="Test", + supported_task_scopes=frozenset({"region"}), + default_regions=("global",), + ) - observed_tasks: list[list[str]] = [] + def resolve_target_filters(self, *, target, include_override, exclude_override): + return target.include, target.exclude + + def validate_target(self, target): + return None + + def auth_cache_key(self, target): + return None + + def auth_check(self, target): + return ProviderAuthResult( + status=results.ExecutionStatus.SUCCESS, source="test" + ) - class FakeResolver: - def __init__(self, *, descriptor, context, **kwargs): - self.descriptor = descriptor - self.context = context + def prepare_target(self, **kwargs): + return ProviderPreparation() - def resolve_accounts(self): - return [] + def resolve_execution_targets(self, **kwargs): + return ProviderExecutionPlan(execution_targets=[]) + + monkeypatch.setattr(runner, "_load_provider", lambda provider_name: FakeProvider()) + + observed_tasks: list[list[str]] = [] def fake_execute_provider_targets(*, target, context, execution_targets, **kwargs): observed_tasks.append([task.name for task in context.tasks]) return results.TargetResult.create( config_branch=target.config_branch, target_name=target.name, + provider=target.provider, dry_run=context.dry_run, entities=[], ) - monkeypatch.setattr(runner, "OrganizationResolver", FakeResolver) monkeypatch.setattr( runner, "_execute_provider_targets", fake_execute_provider_targets ) diff --git a/tests/test_loader_plugin_entry_points.py b/tests/test_loader_plugin_entry_points.py index 1014f73..7d14b78 100644 --- a/tests/test_loader_plugin_entry_points.py +++ b/tests/test_loader_plugin_entry_points.py @@ -93,6 +93,14 @@ def _clear_task_loader_caches() -> None: task_loader._resolve_tasks_cached.cache_clear() +def _resolve_tasks(*, task_specs, provider_name): + return task_loader.resolve_tasks( + task_specs=task_specs, + provider_name=provider_name, + supported_task_scopes=frozenset({"region", "target"}), + ) + + def test_universal_provider_plugin_task_resolves_for_all_providers( monkeypatch, tmp_path ): @@ -110,7 +118,7 @@ def test_universal_provider_plugin_task_resolves_for_all_providers( _clear_task_loader_caches() for provider_name in ("aws", "azure", "gcp", "github"): - execution = task_loader.resolve_tasks( + execution = _resolve_tasks( task_specs=[{"name": "universal_plugin_task"}], provider_name=provider_name ) @@ -142,7 +150,7 @@ def test_provider_specific_plugin_task_resolves_only_for_own_provider( importlib.invalidate_caches() _clear_task_loader_caches() - execution = task_loader.resolve_tasks( + execution = _resolve_tasks( task_specs=[{"name": task_name}], provider_name=provider_name ) assert execution.ordered[0].run() == f"{provider_name}-plugin" @@ -150,7 +158,7 @@ def test_provider_specific_plugin_task_resolves_only_for_own_provider( other_providers = {"aws", "azure", "gcp", "github"} - {provider_name} for other_provider in other_providers: with pytest.raises(task_loader.TaskConfigError, match="not available"): - task_loader.resolve_tasks( + _resolve_tasks( task_specs=[{"name": task_name}], provider_name=other_provider ) @@ -170,9 +178,7 @@ def test_duplicate_plugin_and_builtin_task_name_is_ambiguous(monkeypatch, tmp_pa _clear_task_loader_caches() with pytest.raises(task_loader.TaskConfigError) as exc_info: - task_loader.resolve_tasks( - task_specs=[{"name": "count_vpc"}], provider_name="aws" - ) + _resolve_tasks(task_specs=[{"name": "count_vpc"}], provider_name="aws") error = str(exc_info.value) assert "ambiguous for provider 'aws'" in error @@ -206,16 +212,14 @@ def test_duplicate_universal_and_provider_plugin_names_are_ambiguous( _clear_task_loader_caches() with pytest.raises(task_loader.TaskConfigError) as exc_info: - task_loader.resolve_tasks( - task_specs=[{"name": "shared_plugin_task"}], provider_name="aws" - ) + _resolve_tasks(task_specs=[{"name": "shared_plugin_task"}], provider_name="aws") error = str(exc_info.value) assert "ambiguous for provider 'aws'" in error assert "universal plugin: anvil-test-shared-universal-plugin" in error assert "aws plugin: anvil-test-shared-aws-plugin" in error - azure_execution = task_loader.resolve_tasks( + azure_execution = _resolve_tasks( task_specs=[{"name": "shared_plugin_task"}], provider_name="azure" ) assert azure_execution.ordered[0].run() == "universal" @@ -235,7 +239,7 @@ def test_duplicate_same_distribution_plugin_names_fail_full_task_validation( _clear_task_loader_caches() with pytest.raises(task_loader.TaskConfigError, match="ambiguous"): - task_loader.resolve_tasks( + _resolve_tasks( task_specs=[{"name": "duplicated_plugin_task"}], provider_name="aws" ) @@ -334,7 +338,7 @@ def test_discover_processors_includes_real_plugin_entry_point(monkeypatch, tmp_p if processor.name == "real_plugin_processor" ) - assert descriptor.source == "plugin: anvil-test-processor-plugin" + assert str(descriptor.source) == "plugin: anvil-test-processor-plugin" assert "anvil_test_processor_plugin.real_plugin_processor" not in sys.modules assert descriptor.load()( context=None, output="report.md", metadata={"ok": True} diff --git a/tests/validators/test_config_loading.py b/tests/validators/test_config_loading.py index 3434ed6..0836da7 100644 --- a/tests/validators/test_config_loading.py +++ b/tests/validators/test_config_loading.py @@ -298,7 +298,6 @@ def test_discovery_modes_allow_omitted_include(provider, mode): [ ("aws", "accounts"), ("azure", "subscriptions"), - ("gcp", "projects"), ("github", "organizations"), ("github", "repositories"), ], @@ -306,13 +305,13 @@ def test_discovery_modes_allow_omitted_include(provider, mode): def test_explicit_modes_require_include_and_reject_exclude(provider, mode): validators = _import_validators_or_skip() + config = { + "schema_version": 2, + "targets": [_target(provider_name=provider, mode=mode)], + } + validators.validate_config_schema(config=config) with pytest.raises(ValueError, match="include"): - validators.validate_config_schema( - config={ - "schema_version": 2, - "targets": [_target(provider_name=provider, mode=mode)], - } - ) + validators.load_config_descriptors(config=config) with pytest.raises(ValueError, match="exclude"): validators.validate_config_schema( @@ -338,14 +337,12 @@ def test_github_modes_validate_include_shape(mode, include): validators = _import_validators_or_skip() with pytest.raises(ValueError, match="include"): - validators.validate_config_schema( - config={ - "schema_version": 2, - "targets": [ - _target(provider_name="github", mode=mode, include=include) - ], - } - ) + config = { + "schema_version": 2, + "targets": [_target(provider_name="github", mode=mode, include=include)], + } + validators.validate_config_schema(config=config) + validators.load_config_descriptors(config=config) @pytest.mark.parametrize("field_name", ["provider_options", "profile", "role_name"]) diff --git a/tests/validators/test_org_validation.py b/tests/validators/test_org_validation.py index 306e7f8..515b34a 100644 --- a/tests/validators/test_org_validation.py +++ b/tests/validators/test_org_validation.py @@ -1,6 +1,21 @@ import pytest from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.providers.aws.provider import AwsProvider +from anvil.providers.azure.provider import AzureProvider +from anvil.providers.gcp.provider import GcpProvider +from anvil.providers.github.provider import GithubProvider + + +def _aws_org(**overrides) -> TargetDescriptor: + values = { + "config_branch": ConfigBranch.TARGETS, + "name": "org", + "provider": "aws", + "mode": "organization", + } + values.update(overrides) + return TargetDescriptor(**values) def test_duplicate_org_names(): @@ -9,10 +24,7 @@ def test_duplicate_org_names(): except PermissionError as error: pytest.skip(f"jsonschema package resources unavailable in test env: {error}") - targets = [ - TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="a"), - TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="a"), - ] + targets = [_aws_org(name="a"), _aws_org(name="a")] with pytest.raises(ValueError): validate_target_descriptors(targets=targets) @@ -22,21 +34,27 @@ def test_accounts_direct_mode_requires_single_account(): with pytest.raises( ValueError, match="without role_name must include exactly one account ID" ): - TargetDescriptor( + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="direct-rollout", + provider="aws", + mode="accounts", include=["111111111111", "222222222222"], ) + AwsProvider().validate_target(descriptor) def test_accounts_assume_role_mode_allows_multiple_accounts(): descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="assume-role-rollout", - role_name="OrganizationAccountAccessRole", + provider="aws", + mode="accounts", + provider_options={"role_name": "OrganizationAccountAccessRole"}, include=["111111111111", "222222222222"], ) + AwsProvider().validate_target(descriptor) assert descriptor.include == ["111111111111", "222222222222"] @@ -102,38 +120,47 @@ def test_github_repository_mode_allows_owner_repo_values(): def test_github_modes_require_include(): with pytest.raises(ValueError, match="requires include"): - TargetDescriptor( + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="github-repositories", provider="github", mode="repositories", ) + GithubProvider().validate_target(descriptor) -def test_invalid_provider_is_rejected(): - with pytest.raises(ValueError, match="Unsupported provider"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="unknown", - provider="do", - include=["target-a"], +def test_unknown_provider_is_rejected_during_component_resolution(): + from anvil.validators import validate_target_descriptors + + with pytest.raises(ValueError, match="Unknown provider"): + validate_target_descriptors( + targets=[ + TargetDescriptor( + config_branch=ConfigBranch.TARGETS, + name="unknown", + provider="do", + mode="custom", + include=["target-a"], + ) + ] ) def test_invalid_provider_mode_is_rejected(): - with pytest.raises(ValueError, match="Unsupported mode"): - TargetDescriptor( + with pytest.raises(ValueError, match="Unsupported Azure target mode"): + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="azure-subscriptions", provider="azure", mode="projects", include=["sub-a"], ) + AzureProvider().validate_target(descriptor) def test_invalid_provider_options_are_rejected(): with pytest.raises(ValueError, match="Unsupported provider.options"): - TargetDescriptor( + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="gcp-projects", provider="gcp", @@ -141,97 +168,46 @@ def test_invalid_provider_options_are_rejected(): include=["project-a"], provider_options={"tenant_id": "wrong-cloud"}, ) - - -def test_provider_options_profile_conflict_is_rejected(): - with pytest.raises(ValueError, match="provider.options.profile"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="aws-accounts", - profile="dev", - include=["111111111111"], - provider_options={"profile": "prod"}, - ) - - -def test_provider_options_role_name_conflict_is_rejected(): - with pytest.raises(ValueError, match="provider.options.role_name"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="aws-accounts", - role_name="AuditRole", - include=["111111111111"], - provider_options={"role_name": "ReadOnlyRole"}, - ) - - -def test_matching_top_level_and_provider_options_profile_is_accepted(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="aws-accounts", - profile="dev", - include=["111111111111"], - provider_options={"profile": "dev"}, - ) - - assert descriptor.profile == "dev" - - -def test_matching_top_level_and_provider_options_role_name_is_accepted(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="aws-accounts", - role_name="AuditRole", - include=["111111111111", "222222222222"], - provider_options={"role_name": "AuditRole"}, - ) - - assert descriptor.role_name == "AuditRole" + GcpProvider().validate_target(descriptor) def test_max_parallel_regions_defaults_to_one(): - descriptor = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="org") + descriptor = _aws_org() assert descriptor.max_parallel_regions == 1 def test_max_parallel_regions_accepts_maximum_value(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org", max_parallel_regions=4 - ) + descriptor = _aws_org(max_parallel_regions=4) assert descriptor.max_parallel_regions == 4 def test_organization_regions_accepts_all_selector(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org", regions=["all"] - ) + descriptor = _aws_org(regions=["all"]) + AwsProvider().validate_target(descriptor) assert descriptor.regions == ["all"] def test_organization_regions_accepts_globs_and_explicit_regions(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org", regions=["us-*", "ca-central-1"] - ) + descriptor = _aws_org(regions=["us-*", "ca-central-1"]) + AwsProvider().validate_target(descriptor) assert descriptor.regions == ["us-*", "ca-central-1"] def test_post_run_defaults_to_empty_list(): - descriptor = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="org") + descriptor = _aws_org() assert descriptor.post_run == [] def test_post_run_normalizes_processor_and_metadata(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", + descriptor = _aws_org( post_run=[ {"processor": " summary_markdown ", "metadata": {"include_passed": False}} - ], + ] ) assert descriptor.post_run == [ @@ -240,10 +216,8 @@ def test_post_run_normalizes_processor_and_metadata(): def test_post_run_normalizes_run_on_failure(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", - post_run=[{"processor": "html_report", "run_on_failure": True}], + descriptor = _aws_org( + post_run=[{"processor": "html_report", "run_on_failure": True}] ) assert descriptor.post_run == [ @@ -253,20 +227,21 @@ def test_post_run_normalizes_run_on_failure(): 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.TARGETS, name="org", regions=["all", "us-east-1"] - ) + AwsProvider().validate_target(_aws_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( + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="group", + provider="aws", + mode="accounts", include=["111111111111"], regions=regions, ) + AwsProvider().validate_target(descriptor) @pytest.mark.parametrize( @@ -287,12 +262,15 @@ def test_provider_location_discovery_modes_accept_selectors(provider, mode, incl regions=["us-*"], ) + {"azure": AzureProvider(), "gcp": GcpProvider()}[provider].validate_target( + descriptor + ) assert descriptor.regions == ["us-*"] def test_github_repository_regions_reject_selectors(): with pytest.raises(ValueError, match="selectors are not allowed"): - TargetDescriptor( + descriptor = TargetDescriptor( config_branch=ConfigBranch.TARGETS, name="github-repos", provider="github", @@ -300,28 +278,19 @@ def test_github_repository_regions_reject_selectors(): include=["octo-org/example"], regions=["all"], ) + GithubProvider().validate_target(descriptor) @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"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", - max_parallel_regions=max_parallel_regions, - ) + _aws_org(max_parallel_regions=max_parallel_regions) def test_fail_fast_warns_when_combined_concurrency_is_high(caplog): from anvil.validators import validate_target_descriptors - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", - max_workers=3, - max_parallel_regions=4, - fail_fast=True, - ) + target = _aws_org(max_workers=3, max_parallel_regions=4, fail_fast=True) validate_target_descriptors(targets=[target]) @@ -331,13 +300,7 @@ def test_fail_fast_warns_when_combined_concurrency_is_high(caplog): def test_fail_fast_does_not_warn_when_combined_concurrency_is_low(caplog): from anvil.validators import validate_target_descriptors - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", - max_workers=2, - max_parallel_regions=4, - fail_fast=True, - ) + target = _aws_org(max_workers=2, max_parallel_regions=4, fail_fast=True) validate_target_descriptors(targets=[target]) From 92d3b7081c49f38949626d17f2640d9d276dc018 Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:18:10 -0500 Subject: [PATCH 2/8] fix: Refactor validation, context isolation, and catalog APIs - Extract `validate_keyword_only_invocation` into `_components.py` and reuse it for both task and processor signature validation, replacing duplicated inspection logic - Replace `task_validation_errors(resolved)` with `task_validation_errors(descriptors)` so validation loads callables from descriptors directly; add `task_catalog_ambiguity_errors` for provider-scoped duplicate detection - Split `_provider_task_discovery` into `_universal_task_catalog` and `_provider_specific_task_catalog` caches so universal tasks are discovered once across all providers - `ProcessorRunContext` now snapshots mutable inputs in `__post_init__`, derives `target_result` and `target_result_path` as properties, and validates target-selection invariants - `TaskCallContext.to_kwargs()` returns a fresh metadata copy; add `keyword_names()` classmethod - `run_processors` passes a `replace(context)` snapshot and a fresh metadata dict to each processor invocation - Remove `ConfigBranch` dependency from `ProcessorRunContext`, `html_report`, `sarif_report`, and related call sites - Drop `kind` field from `PackageComponentSource`; add `provider` parameter to `source_from_entry_point` - Add docstring validation for processors and expand test coverage for isolation, ambiguity, and context invariants --- .../references/runtime-contract.md | 3 + README.md | 9 +- src/anvil/_components.py | 51 +++++- src/anvil/cli.py | 23 +-- src/anvil/execution_context.py | 2 +- src/anvil/processor_loader.py | 86 +++++++-- src/anvil/processor_validation.py | 38 ++-- src/anvil/processors/html_report.py | 48 ++--- src/anvil/processors/sarif_report.py | 22 +-- src/anvil/provider_loader.py | 2 - src/anvil/task_context.py | 22 +-- src/anvil/task_loader.py | 142 +++++++-------- src/anvil/task_validation.py | 117 +++++++------ tests/cli/test_cli_smoke.py | 3 - tests/cli/test_validate_command.py | 40 +++-- tests/processors/test_processors.py | 115 +++++++++++- tests/tasks/test_provider_task_loader.py | 84 +++++---- tests/tasks/test_task_context.py | 35 ++++ tests/tasks/test_task_loader.py | 20 ++- tests/tasks/test_task_loader_cache.py | 18 +- .../test_task_loader_cache_integration.py | 14 +- tests/tasks/test_task_validation.py | 165 ++++++++++++------ tests/test_component_catalog.py | 2 - tests/test_loader_plugin_entry_points.py | 12 +- 24 files changed, 683 insertions(+), 390 deletions(-) create mode 100644 tests/tasks/test_task_context.py diff --git a/.agents/skills/anvil-task-builder/references/runtime-contract.md b/.agents/skills/anvil-task-builder/references/runtime-contract.md index 143d050..b672cb1 100644 --- a/.agents/skills/anvil-task-builder/references/runtime-contract.md +++ b/.agents/skills/anvil-task-builder/references/runtime-contract.md @@ -88,6 +88,9 @@ Runtime facts: - For region-scoped tasks, `region` is the current task execution region. AWS sessions also expose `session.region_name`. - Operator-provided task inputs come from `metadata`. +- Tasks should treat metadata as read-only configuration. + Anvil isolates changes to top-level metadata keys, + but not changes inside nested lists or dictionaries. - `actions` is an `ActionRecorder` for audit-level actions. - Returned values are included in Anvil result JSON. - The engine already includes execution context such as target identity, `region`, and `dry_run` in normal results. diff --git a/README.md b/README.md index b377cb4..a08897d 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,9 @@ Run focused validation categories: anvil validate --tasks --processors --auth --config-file ./yaml/orgs.yaml ``` -`--tasks` and `--processors` validate discovery and callable signatures. +`--tasks` and `--processors` validate discovery, keyword-only callable +signatures, and operator-facing detail documentation. Validation rejects +additional required parameters that Anvil cannot supply at runtime. `--providers` validates the provider contract. `--auth` validates cloud access for the configured targets after loading and validating the config file. @@ -279,6 +281,11 @@ See more at [Task validation](https://opsfoundry.dev/anvil/task-contract/#task-v Processors run after a target finishes and turn Anvil results into reports or integration artifacts. Use them for formats that should stay outside task logic, such as HTML, SARIF, Markdown, JSON summaries, tickets, or notification payloads. +Processor modules expose a documented keyword-only +`run(*, context, output, metadata)` callable. `context.target_results` is the +canonical result collection; target-level runs additionally set +`context.target_name`, from which `target_result` and `target_result_path` are +derived. Treat context data and processor metadata as invocation snapshots. Target `post_run` processor output is written under the run's `reports` directory, so `output: smoke.html` becomes `/reports/smoke.html`. diff --git a/src/anvil/_components.py b/src/anvil/_components.py index 33d7d8b..1ea93e6 100644 --- a/src/anvil/_components.py +++ b/src/anvil/_components.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field from enum import StrEnum from importlib.metadata import EntryPoint +from inspect import Parameter, signature from types import MappingProxyType from typing import Generic, TypeVar @@ -108,7 +109,6 @@ class ComponentResolutionError(RuntimeError): class PackageComponentSource(Generic[T]): """Discover immediate public children of one importable package root.""" - kind: ComponentKind package_name: str source: ComponentSource component_loader: Callable[[str, str, ComponentSource], T] @@ -203,8 +203,54 @@ def load(self, name: str) -> T: return self.descriptor(name).load() +def validate_keyword_only_invocation( + callable_object: Callable[..., object], *, keyword_names: frozenset[str] +) -> None: + """Validate that a callable accepts the supplied runtime keywords. + + Additional optional keyword-only parameters and ``**kwargs`` are allowed. + Additional required parameters are rejected because the runtime cannot + supply them. + + Args: + callable_object: Callable to inspect. + keyword_names: Runtime keyword names that will always be supplied. + + Raises: + ValueError: If the signature cannot accept the runtime invocation. + """ + + try: + callable_signature = signature(callable_object) + except (TypeError, ValueError) as error: + raise ValueError("unable to inspect callable signature") from error + + parameters = callable_signature.parameters + missing = keyword_names - set(parameters) + if missing: + raise ValueError(f"missing required parameters: {sorted(missing)}") + + unsupported_parameters = sorted( + parameter.name + for parameter in parameters.values() + if parameter.kind not in {Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD} + ) + if unsupported_parameters: + raise ValueError(f"parameters must be keyword-only: {unsupported_parameters}") + + invocation_kwargs = {name: object() for name in keyword_names} + try: + callable_signature.bind(**invocation_kwargs) + except TypeError as error: + raise ValueError(f"cannot be invoked with runtime keywords: {error}") from error + + def source_from_entry_point( - *, entry_point: EntryPoint, package: str, label_prefix: str = "plugin:" + *, + entry_point: EntryPoint, + package: str, + label_prefix: str = "plugin:", + provider: str | None = None, ) -> ComponentSource: """Build structured source metadata for a package entry point.""" @@ -217,6 +263,7 @@ def source_from_entry_point( distribution=distribution, entry_point_group=entry_point.group, entry_point_name=entry_point.name, + provider=provider, ) diff --git a/src/anvil/cli.py b/src/anvil/cli.py index 1742e0d..a933654 100644 --- a/src/anvil/cli.py +++ b/src/anvil/cli.py @@ -58,8 +58,8 @@ ) from anvil.results import EngineResult, EngineState from anvil.runner import run_auth_checks, run_multiple_targets -from anvil.task_loader import ResolvedTask, TaskDescriptor, discover_tasks, list_tasks -from anvil.task_validation import task_validation_errors +from anvil.task_loader import TaskDescriptor, discover_tasks, list_tasks +from anvil.task_validation import task_catalog_ambiguity_errors, task_validation_errors from anvil.validators import load_config_descriptors, validate_config_schema __LOGGER__ = logging.getLogger(__name__) @@ -337,7 +337,6 @@ def _run_single_config_file(*, config_file: Path, args: argparse.Namespace) -> i config_file=config_file, engine_result=engine_result ) run_configured_post_processors( - config_branch=loaded_config.branch, targets=loaded_config.targets, target_results=engine_result.target_results, run_dir=written_results.run_dir, @@ -554,19 +553,13 @@ def _validate_selected_tasks(task_names: list[str] | None) -> None: else: errors.extend(_discovery_issue_messages(discovery.issues)) - resolved = [] - for descriptor in descriptors: - try: - run = descriptor.load() - except Exception as exc: - errors.append(f"{descriptor.name} ({descriptor.source}): {exc}") - continue - - resolved.append( - ResolvedTask(name=descriptor.name, run=run, depends_on=[], optional=False) + provider_catalogs = getattr(discovery, "provider_catalogs", {}) + errors.extend( + task_catalog_ambiguity_errors( + provider_catalogs, task_names=set(task_names) if task_names else None ) - - errors.extend(task_validation_errors(resolved)) + ) + errors.extend(task_validation_errors(descriptors)) _raise_validation_errors(errors) diff --git a/src/anvil/execution_context.py b/src/anvil/execution_context.py index 0f62168..00b7c45 100644 --- a/src/anvil/execution_context.py +++ b/src/anvil/execution_context.py @@ -9,7 +9,7 @@ @dataclass(frozen=True, slots=True) class ExecutionContext: """ - Immutable execution configuration shared across org and account execution. + Immutable execution configuration shared across provider target execution. """ regions: list[str] diff --git a/src/anvil/processor_loader.py b/src/anvil/processor_loader.py index 7b7127a..634366d 100644 --- a/src/anvil/processor_loader.py +++ b/src/anvil/processor_loader.py @@ -2,8 +2,8 @@ import json import logging -from collections.abc import Callable, Sequence -from dataclasses import dataclass, field +from collections.abc import Callable +from dataclasses import dataclass, field, replace from functools import lru_cache from pathlib import Path @@ -20,7 +20,7 @@ PackageComponentSource, source_from_entry_point, ) -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.results import TargetResult __LOGGER__ = logging.getLogger(__name__) @@ -61,16 +61,61 @@ class ProcessorDiscoveryResult: class ProcessorRunContext: """Completed run data passed to post-run processors.""" - config_branch: ConfigBranch run_dir: Path summary_path: Path summary: dict[str, object] target_result_paths: dict[str, Path] target_name: str | None = None - target_result: dict[str, object] | None = None - target_result_path: Path | None = None target_metadata: dict[str, object] = field(default_factory=dict) - target_results: Sequence[dict[str, object]] = field(default_factory=list) + target_results: tuple[dict[str, object], ...] = () + + def __post_init__(self) -> None: + """Snapshot mutable inputs and validate target-selection invariants.""" + + object.__setattr__(self, "summary", dict(self.summary)) + object.__setattr__(self, "target_result_paths", dict(self.target_result_paths)) + object.__setattr__(self, "target_metadata", dict(self.target_metadata)) + object.__setattr__( + self, + "target_results", + tuple(dict(target_result) for target_result in self.target_results), + ) + + if self.target_name is None: + if self.target_metadata: + raise ValueError("target_metadata requires a selected processor target") + return + + matching_results = [ + target_result + for target_result in self.target_results + if target_result.get("target") == self.target_name + ] + if len(matching_results) != 1: + raise ValueError( + f"selected processor target '{self.target_name}' must have " + "exactly one matching target result" + ) + + @property + def target_result(self) -> dict[str, object] | None: + """Return the selected target result, if this is a target-level run.""" + + if self.target_name is None: + return None + return next( + target_result + for target_result in self.target_results + if target_result.get("target") == self.target_name + ) + + @property + def target_result_path(self) -> Path | None: + """Return the selected target's result path, if available.""" + + if self.target_name is None: + return None + return self.target_result_paths.get(self.target_name) # ============================================================================ @@ -197,7 +242,6 @@ def _processor_specs_by_target_name( def run_configured_post_processors( *, - config_branch: ConfigBranch, targets: list[TargetDescriptor], target_results: list[TargetResult], run_dir: Path, @@ -224,16 +268,13 @@ def run_configured_post_processors( continue context = ProcessorRunContext( - config_branch=config_branch, run_dir=run_dir, summary_path=summary_path, summary=summary, target_result_paths=target_result_paths, target_name=target_result.target_name, - target_result=target_result.to_dict(), - target_result_path=target_result_paths.get(target_result.target_name), target_metadata=dict(target.metadata), - target_results=[target_result.to_dict()], + target_results=(target_result.to_dict(),), ) resolved_specs: list[ProcessorSpec] = [] @@ -294,7 +335,6 @@ def _processor_catalog_for_entry_points( origin=ComponentOrigin.STOCK, package="anvil.processors", label="stock" ) stock_descriptors, stock_issues = PackageComponentSource( - kind=ComponentKind.PROCESSOR, package_name="anvil.processors", source=stock_source, component_loader=_load_processor_from_package, @@ -306,7 +346,6 @@ def _processor_catalog_for_entry_points( package_name = entry_point.value.split(":", maxsplit=1)[0] source = source_from_entry_point(entry_point=entry_point, package=package_name) plugin_descriptors, plugin_issues = PackageComponentSource( - kind=ComponentKind.PROCESSOR, package_name=package_name, source=source, component_loader=_load_processor_from_package, @@ -328,7 +367,6 @@ def _processor_catalog() -> ComponentCatalog[Callable]: # ============================================================================ -@lru_cache(maxsize=128) def load_processor_callable(processor_name: str) -> Callable: """Resolve a processor run callable by name.""" return ComponentResolver( @@ -338,6 +376,12 @@ def load_processor_callable(processor_name: str) -> Callable: ).load(processor_name) +def _clear_processor_caches() -> None: + """Clear the processor discovery cache.""" + + _processor_catalog_for_entry_points.cache_clear() + + def discover_processors() -> ProcessorDiscoveryResult: """Discover processors and report plugin packages that cannot be inspected.""" catalog = _processor_catalog() @@ -362,11 +406,18 @@ def run_processors( for spec in specs: run = load_processor_callable(spec.processor) + invocation_context = replace(context) __LOGGER__.info( f"Running processor '{spec.processor}' for target " f"'{context.target_name or 'run'}'" ) - results.append(run(context=context, output=spec.output, metadata=spec.metadata)) + results.append( + run( + context=invocation_context, + output=spec.output, + metadata=dict(spec.metadata), + ) + ) return results @@ -397,12 +448,11 @@ def load_completed_run_context(*, results_dir: Path) -> ProcessorRunContext: target_result_paths[target_name] = result_path return ProcessorRunContext( - config_branch=ConfigBranch.TARGETS, run_dir=results_dir, summary_path=summary_path, summary=summary, target_result_paths=target_result_paths, - target_results=target_results, + target_results=tuple(target_results), ) diff --git a/src/anvil/processor_validation.py b/src/anvil/processor_validation.py index 9a16ec3..cc60bdf 100644 --- a/src/anvil/processor_validation.py +++ b/src/anvil/processor_validation.py @@ -9,11 +9,12 @@ from __future__ import annotations from collections.abc import Callable -from inspect import Parameter, signature +from inspect import getdoc, getmodule +from anvil._components import validate_keyword_only_invocation from anvil.processor_loader import ProcessorDescriptor -REQUIRED_RUN_KWARGS: set[str] = {"context", "output", "metadata"} +PROCESSOR_RUN_KWARGS = frozenset({"context", "output", "metadata"}) class ProcessorValidationError(ValueError): @@ -58,6 +59,7 @@ def processor_validation_errors(processors: list[ProcessorDescriptor]) -> list[s ) _validate_processor_run_signature(name=processor.name, run=run) + _validate_processor_detail_docstring(name=processor.name, run=run) except Exception as exc: errors.append(f"{processor.name} ({processor.source}): {exc}") @@ -67,26 +69,22 @@ def processor_validation_errors(processors: list[ProcessorDescriptor]) -> list[s def _validate_processor_run_signature(*, name: str, run: Callable) -> None: try: - sig = signature(run) - except (TypeError, ValueError) as exc: + validate_keyword_only_invocation(run, keyword_names=PROCESSOR_RUN_KWARGS) + except ValueError as exc: raise ProcessorValidationError( - f"unable to inspect run() signature for processor '{name}'" + f"processor '{name}' has incompatible run() signature: {exc}" ) from exc - parameters = sig.parameters - accepts_extra_kwargs = any( - parameter.kind is Parameter.VAR_KEYWORD for parameter in parameters.values() - ) - missing = REQUIRED_RUN_KWARGS - set(parameters) - if missing and not accepts_extra_kwargs: + +def _validate_processor_detail_docstring(*, name: str, run: Callable) -> None: + doc = getdoc(run) + if doc is None: + module = getmodule(run) + if module is not None: + doc = getdoc(module) + + if doc is None: raise ProcessorValidationError( - f"processor '{name}' is missing required run() parameters: " - f"{sorted(missing)}" + f"processor '{name}' is missing detail documentation; add a " + "Google-style run() docstring for 'anvil list --processors --detail'" ) - - for parameter in parameters.values(): - if parameter.kind is Parameter.POSITIONAL_ONLY: - raise ProcessorValidationError( - f"processor '{name}' uses positional-only parameter " - f"'{parameter.name}', which is not supported" - ) diff --git a/src/anvil/processors/html_report.py b/src/anvil/processors/html_report.py index 711a467..3292209 100644 --- a/src/anvil/processors/html_report.py +++ b/src/anvil/processors/html_report.py @@ -3,9 +3,8 @@ import html import json from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast -from anvil.descriptors import ConfigBranch from anvil.results import TargetResult if TYPE_CHECKING: @@ -64,22 +63,14 @@ def _default_output_path(context: ProcessorRunContext) -> Path: def _load_records(*, context: ProcessorRunContext) -> list[dict[str, object]]: records: list[dict[str, object]] = [] if context.target_result is not None: - records.extend( - _records_from_target_result( - target_result=context.target_result, config_branch=context.config_branch - ) - ) + records.extend(_records_from_target_result(target_result=context.target_result)) else: for target_result in context.target_results: if not _matches_context_target( context=context, target_result=target_result ): continue - records.extend( - _records_from_target_result( - target_result=target_result, config_branch=context.config_branch - ) - ) + records.extend(_records_from_target_result(target_result=target_result)) return records @@ -93,33 +84,23 @@ def _matches_context_target( if isinstance(target_result, TargetResult): return target_result.target_name == context.target_name - target_type = _target_type(context.config_branch) - target_name = _string_value(target_result.get(target_type)) or _string_value( - target_result.get("target") - ) + target_name = _string_value(target_result.get("target")) return target_name == context.target_name def _records_from_target_result( - *, target_result: TargetResult | dict[str, object], config_branch: ConfigBranch + *, target_result: TargetResult | dict[str, object] ) -> list[dict[str, object]]: if isinstance(target_result, TargetResult): - return _records_from_target_dict( - target_result=target_result.to_dict(), config_branch=config_branch - ) + return _records_from_target_dict(target_result=target_result.to_dict()) - return _records_from_target_dict( - target_result=target_result, config_branch=config_branch - ) + return _records_from_target_dict(target_result=target_result) def _records_from_target_dict( - *, target_result: dict[str, object], config_branch: ConfigBranch + *, target_result: dict[str, object] ) -> list[dict[str, object]]: - target_type = _target_type(config_branch) - target_name = _string_value(target_result.get(target_type)) or _string_value( - target_result.get("target") - ) + target_name = _string_value(target_result.get("target")) entities = target_result.get("entities", []) if not isinstance(entities, list): return [] @@ -128,10 +109,10 @@ def _records_from_target_dict( for entity_result in entities: if not isinstance(entity_result, dict): continue + entity_result = cast(dict[str, object], entity_result) entity_record = { - "target_type": target_type, - target_type: target_name, + "target_type": "target", "target": target_name, "generated_at": target_result.get("generated_at"), "dry_run": target_result.get("dry_run"), @@ -155,6 +136,7 @@ def _records_from_target_dict( for task_result in tasks: if not isinstance(task_result, dict): continue + task_result = cast(dict[str, object], task_result) records.append( { **entity_record, @@ -170,12 +152,6 @@ def _records_from_target_dict( return records -def _target_type(config_branch: ConfigBranch) -> str: - if config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported config branch: {config_branch}") - return "target" - - def _timed_status_record(record: dict[str, object]) -> dict[str, object]: return { "status": record.get("status"), diff --git a/src/anvil/processors/sarif_report.py b/src/anvil/processors/sarif_report.py index 8e73648..e58c04e 100644 --- a/src/anvil/processors/sarif_report.py +++ b/src/anvil/processors/sarif_report.py @@ -2,9 +2,8 @@ import json from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast -from anvil.descriptors import ConfigBranch from anvil.results import TargetResult if TYPE_CHECKING: @@ -86,12 +85,12 @@ def _collect_sarif_results( "sarif_report requires every sarif_findings entry " "to be a mapping" ) + raw_finding = cast(dict[str, object], raw_finding) sarif_result, rule = _convert_finding( finding=raw_finding, target_result=target_result, entity_result=entity_result, task_result=task_result, - config_branch=context.config_branch, ) rule_id = _required_string(rule, "id", "finding.rule") existing_rule = rules.get(rule_id) @@ -129,7 +128,9 @@ def _entity_results(*, target_result: dict[str, object]) -> list[dict[str, objec if not isinstance(entities, list): return [] - return [item for item in entities if isinstance(item, dict)] + return [ + cast(dict[str, object], item) for item in entities if isinstance(item, dict) + ] def _task_results(*, entity_result: dict[str, object]) -> list[dict[str, object]]: @@ -137,7 +138,9 @@ def _task_results(*, entity_result: dict[str, object]) -> list[dict[str, object] if not isinstance(task_results, list): return [] - return [item for item in task_results if isinstance(item, dict)] + return [ + cast(dict[str, object], item) for item in task_results if isinstance(item, dict) + ] def _convert_finding( @@ -146,7 +149,6 @@ def _convert_finding( target_result: dict[str, object], entity_result: dict[str, object], task_result: dict[str, object], - config_branch: ConfigBranch, ) -> tuple[dict[str, object], dict[str, object]]: rule = _rule_descriptor(finding=finding) rule_id = _required_string(rule, "id", "finding.rule") @@ -165,7 +167,6 @@ def _convert_finding( target_result=target_result, entity_result=entity_result, task_result=task_result, - config_branch=config_branch, ), } if isinstance(fingerprint, str) and fingerprint.strip(): @@ -178,6 +179,7 @@ def _rule_descriptor(*, finding: dict[str, object]) -> dict[str, object]: raw_rule = finding.get("rule") if not isinstance(raw_rule, dict): raise RuntimeError("sarif_report requires finding.rule to be a mapping") + raw_rule = cast(dict[str, object], raw_rule) rule_id = _required_string(raw_rule, "id", "finding.rule") rule: dict[str, object] = {"id": rule_id} @@ -236,6 +238,7 @@ def _locations(*, finding: dict[str, object]) -> list[dict[str, object]]: def _location(raw_location: object) -> dict[str, object]: if not isinstance(raw_location, dict): raise RuntimeError("sarif_report requires every location to be a mapping") + raw_location = cast(dict[str, object], raw_location) uri = _required_string(raw_location, "uri", "finding.location") physical_location: dict[str, object] = {"artifactLocation": {"uri": uri}} @@ -272,10 +275,7 @@ def _result_properties( target_result: dict[str, object], entity_result: dict[str, object], task_result: dict[str, object], - config_branch: ConfigBranch, ) -> dict[str, object]: - if config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported config branch: {config_branch}") target_key = "target" properties: dict[str, object] = { @@ -289,7 +289,7 @@ def _result_properties( } raw_properties = finding.get("properties") if isinstance(raw_properties, dict): - properties.update(raw_properties) + properties.update(cast(dict[str, object], raw_properties)) return {key: value for key, value in properties.items() if value is not None} diff --git a/src/anvil/provider_loader.py b/src/anvil/provider_loader.py index 971a55a..2007339 100644 --- a/src/anvil/provider_loader.py +++ b/src/anvil/provider_loader.py @@ -92,7 +92,6 @@ def _provider_catalog_for_entry_points( origin=ComponentOrigin.STOCK, package=_STOCK_PROVIDER_PACKAGE, label="stock" ) stock_descriptors, stock_issues = PackageComponentSource( - kind=ComponentKind.PROVIDER, package_name=_STOCK_PROVIDER_PACKAGE, source=stock_source, component_loader=_load_provider_from_package, @@ -108,7 +107,6 @@ def _provider_catalog_for_entry_points( package_name = entry_point_value.split(":", maxsplit=1)[0] source = _entry_point_source(entry_point) package_descriptors, package_issues = PackageComponentSource( - kind=ComponentKind.PROVIDER, package_name=package_name, source=source, component_loader=_load_provider_from_package, diff --git a/src/anvil/task_context.py b/src/anvil/task_context.py index e4e3e32..a291e75 100644 --- a/src/anvil/task_context.py +++ b/src/anvil/task_context.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields from anvil.actions import ActionRecorder @@ -19,17 +19,17 @@ class TaskCallContext: metadata: dict[str, object] actions: ActionRecorder + @classmethod + def keyword_names(cls) -> frozenset[str]: + """Return the canonical task invocation keyword names.""" + + return frozenset(field.name for field in fields(cls)) + def to_kwargs(self) -> dict[str, object]: """Return provider-neutral task keyword arguments.""" - return { - "provider": self.provider, - "execution_target_id": self.execution_target_id, - "execution_target_name": self.execution_target_name, - "execution_target_type": self.execution_target_type, - "region": self.region, - "session": self.session, - "dry_run": self.dry_run, - "metadata": self.metadata, - "actions": self.actions, + invocation_kwargs = { + field.name: getattr(self, field.name) for field in fields(self) } + invocation_kwargs["metadata"] = dict(self.metadata) + return invocation_kwargs diff --git a/src/anvil/task_loader.py b/src/anvil/task_loader.py index 3c52bc9..03a4e03 100644 --- a/src/anvil/task_loader.py +++ b/src/anvil/task_loader.py @@ -9,6 +9,7 @@ from enum import StrEnum from functools import lru_cache from importlib.metadata import entry_points +from types import MappingProxyType from anvil._components import ( ComponentCatalog, @@ -19,6 +20,7 @@ ComponentSource, DiscoveryIssue, PackageComponentSource, + source_from_entry_point, ) UNIVERSAL_TASK_PACKAGE = "anvil.providers.tasks" @@ -74,6 +76,7 @@ class TaskDiscoveryResult: tasks: list[TaskDescriptor] issues: list[DiscoveryIssue] + provider_catalogs: Mapping[str, ComponentCatalog[Callable]] @dataclass(frozen=True, slots=True) @@ -111,14 +114,14 @@ def _load_task_component( @lru_cache(maxsize=512) def _load_provider_task_callable(*, provider_name: str, task_name: str) -> Callable: - descriptor_index, discovery_issues = _provider_task_discovery(provider_name) - descriptors = descriptor_index.get(task_name, []) + catalog = _provider_task_catalog(provider_name) + descriptors = catalog.inventory.get(task_name, ()) if not descriptors: issue_detail = "" - if discovery_issues: + if catalog.issues: issue_lines = "; ".join( f"{issue.name} ({issue.source}): {issue.error}" - for issue in discovery_issues + for issue in catalog.issues ) issue_detail = ( f" Discovery of one or more task sources failed: {issue_lines}" @@ -132,33 +135,14 @@ def _load_provider_task_callable(*, provider_name: str, task_name: str) -> Calla return ComponentResolver( kind=ComponentKind.TASK, - catalog=ComponentCatalog(descriptors=tuple(descriptors)), + catalog=catalog, error_type=TaskConfigError, context=f"for provider '{provider_name}'", ).load(task_name) -def _provider_task_packages(provider_name: str) -> tuple[tuple[str, str], ...]: - return ( - ("universal", UNIVERSAL_TASK_PACKAGE), - (provider_name, f"{PROVIDER_TASK_PACKAGE_PREFIX}.{provider_name}.tasks"), - ) - - -def _provider_task_entry_point_groups( - provider_name: str, -) -> tuple[tuple[str, str], ...]: - return ( - ("universal", UNIVERSAL_TASK_ENTRY_POINT_GROUP), - ( - provider_name, - f"{PROVIDER_TASK_ENTRY_POINT_GROUP_PREFIX}.{provider_name}.tasks", - ), - ) - - def _iter_package_task_descriptors( - *, package_name: str, source: str + *, package_name: str, source_label: str, provider_name: str | None ) -> list[TaskDescriptor]: try: package_spec = importlib.util.find_spec(package_name) @@ -179,11 +163,10 @@ def _iter_package_task_descriptors( component_source = ComponentSource( origin=ComponentOrigin.STOCK, package=package_name, - label=source, - provider=None if source == "universal" else source, + label=source_label, + provider=provider_name, ) descriptors, issues = PackageComponentSource( - kind=ComponentKind.TASK, package_name=package_name, source=component_source, component_loader=_load_task_component, @@ -195,28 +178,20 @@ def _iter_package_task_descriptors( def _iter_plugin_task_descriptors( - *, entry_point_group: str, source_prefix: str + *, entry_point_group: str, label_prefix: str, provider_name: str | None ) -> tuple[list[TaskDescriptor], list[DiscoveryIssue]]: descriptors: list[TaskDescriptor] = [] issues: list[DiscoveryIssue] = [] for entry_point in entry_points(group=entry_point_group): - distribution = entry_point.dist.name if entry_point.dist is not None else None - source = f"{source_prefix} {distribution or 'unpackaged'}" package_name = entry_point.value.split(":", maxsplit=1)[0] - component_source = ComponentSource( - origin=ComponentOrigin.PLUGIN, + component_source = source_from_entry_point( + entry_point=entry_point, package=package_name, - label=source, - distribution=( - entry_point.dist.name if entry_point.dist is not None else None - ), - entry_point_group=entry_point_group, - entry_point_name=entry_point.name, - provider=None if source_prefix.startswith("universal") else source_prefix, + label_prefix=label_prefix, + provider=provider_name, ) discovered, source_issues = PackageComponentSource( - kind=ComponentKind.TASK, package_name=package_name, source=component_source, component_loader=_load_task_component, @@ -227,33 +202,60 @@ def _iter_plugin_task_descriptors( return descriptors, issues -@lru_cache(maxsize=16) -def _provider_task_discovery( - provider_name: str, -) -> tuple[dict[str, tuple[TaskDescriptor, ...]], tuple[DiscoveryIssue, ...]]: - catalog_descriptors: list[CatalogDescriptor[Callable]] = [] +@lru_cache(maxsize=1) +def _universal_task_catalog() -> ComponentCatalog[Callable]: + descriptors: list[CatalogDescriptor[Callable]] = [] issues: list[DiscoveryIssue] = [] - for source, package_name in _provider_task_packages(provider_name): - catalog_descriptors.extend( - _iter_package_task_descriptors(package_name=package_name, source=source) + descriptors.extend( + _iter_package_task_descriptors( + package_name=UNIVERSAL_TASK_PACKAGE, + source_label="universal", + provider_name=None, ) + ) + plugin_descriptors, plugin_issues = _iter_plugin_task_descriptors( + entry_point_group=UNIVERSAL_TASK_ENTRY_POINT_GROUP, + label_prefix="universal plugin:", + provider_name=None, + ) + descriptors.extend(plugin_descriptors) + issues.extend(plugin_issues) + return ComponentCatalog.build(descriptors, issues) - for source, entry_point_group in _provider_task_entry_point_groups(provider_name): - plugin_descriptors, plugin_issues = _iter_plugin_task_descriptors( - entry_point_group=entry_point_group, source_prefix=f"{source} plugin:" - ) - issues.extend(plugin_issues) - catalog_descriptors.extend(plugin_descriptors) - catalog = ComponentCatalog.build(catalog_descriptors, issues) - return dict(catalog.inventory), catalog.issues +@lru_cache(maxsize=16) +def _provider_specific_task_catalog(provider_name: str) -> ComponentCatalog[Callable]: + descriptors: list[CatalogDescriptor[Callable]] = [] + issues: list[DiscoveryIssue] = [] + + descriptors.extend( + _iter_package_task_descriptors( + package_name=f"{PROVIDER_TASK_PACKAGE_PREFIX}.{provider_name}.tasks", + source_label=provider_name, + provider_name=provider_name, + ) + ) + plugin_descriptors, plugin_issues = _iter_plugin_task_descriptors( + entry_point_group=( + f"{PROVIDER_TASK_ENTRY_POINT_GROUP_PREFIX}.{provider_name}.tasks" + ), + label_prefix=f"{provider_name} plugin:", + provider_name=provider_name, + ) + descriptors.extend(plugin_descriptors) + issues.extend(plugin_issues) + return ComponentCatalog.build(descriptors, issues) -def _provider_task_descriptor_index( - provider_name: str, -) -> dict[str, tuple[TaskDescriptor, ...]]: - return _provider_task_discovery(provider_name)[0] +@lru_cache(maxsize=16) +def _provider_task_catalog(provider_name: str) -> ComponentCatalog[Callable]: + universal_catalog = _universal_task_catalog() + provider_catalog = _provider_specific_task_catalog(provider_name) + return ComponentCatalog.build( + (*universal_catalog.descriptors, *provider_catalog.descriptors), + (*universal_catalog.issues, *provider_catalog.issues), + ) def provider_task_descriptor_index( @@ -263,16 +265,16 @@ def provider_task_descriptor_index( return { name: list(descriptors) - for name, descriptors in _provider_task_descriptor_index(provider_name).items() + for name, descriptors in _provider_task_catalog(provider_name).inventory.items() } def _clear_task_caches() -> None: _load_provider_task_callable.cache_clear() _resolve_tasks_cached.cache_clear() - cache_clear = getattr(_provider_task_discovery, "cache_clear", None) - if cache_clear is not None: - cache_clear() + _provider_task_catalog.cache_clear() + _provider_specific_task_catalog.cache_clear() + _universal_task_catalog.cache_clear() # ============================================================================ @@ -496,17 +498,19 @@ def discover_tasks() -> TaskDiscoveryResult: tasks: set[TaskDescriptor] = set() issues: set[DiscoveryIssue] = set() + provider_catalogs: dict[str, ComponentCatalog[Callable]] = {} for provider_name in sorted({provider.name for provider in list_providers()}): - index, provider_issues = _provider_task_discovery(provider_name) - for descriptors in index.values(): - tasks.update(descriptors) - issues.update(provider_issues) + catalog = _provider_task_catalog(provider_name) + provider_catalogs[provider_name] = catalog + tasks.update(catalog.descriptors) + issues.update(catalog.issues) return TaskDiscoveryResult( tasks=sorted(tasks, key=lambda task: (str(task.source), task.name)), issues=sorted( issues, key=lambda issue: (str(issue.source), issue.name, issue.error) ), + provider_catalogs=MappingProxyType(provider_catalogs), ) diff --git a/src/anvil/task_validation.py b/src/anvil/task_validation.py index 4870729..93b4b5a 100644 --- a/src/anvil/task_validation.py +++ b/src/anvil/task_validation.py @@ -2,105 +2,108 @@ Task validation for Anvil. This module performs *structural* validation of task definitions. -It does not execute tasks or perform any AWS interactions. +It imports task callables for inspection but does not execute them. """ from __future__ import annotations -from inspect import Parameter, getdoc, getmodule, signature +from collections.abc import Callable, Mapping, Sequence +from inspect import getdoc, getmodule -# Required keyword arguments for all task run() functions. -REQUIRED_RUN_KWARGS: set[str] = { - "provider", - "execution_target_id", - "execution_target_name", - "execution_target_type", - "region", - "session", - "dry_run", - "metadata", - "actions", -} +from anvil._components import ( + ComponentCatalog, + ComponentDescriptor, + validate_keyword_only_invocation, +) +from anvil.task_context import TaskCallContext class TaskValidationError(ValueError): """Raised when a task fails structural validation.""" -def validate_tasks(tasks: list) -> None: +TaskDescriptor = ComponentDescriptor[Callable] + + +def validate_tasks(tasks: Sequence[TaskDescriptor]) -> None: + """Validate discovered task descriptors without executing tasks.""" + errors = task_validation_errors(tasks) if errors: raise TaskValidationError("\n - " + "\n - ".join(errors)) -def task_validation_errors(tasks: list) -> list[str]: - """Return structural validation errors for task definitions.""" +def task_validation_errors(tasks: Sequence[TaskDescriptor]) -> list[str]: + """Return structural validation errors for task descriptors.""" + errors: list[str] = [] - seen_names: set[str] = set() for task in tasks: try: if not isinstance(task.name, str) or not task.name: raise TaskValidationError("task name must be a non-empty string") - if task.name in seen_names: - raise TaskValidationError(f"duplicate task name: {task.name}") + if not callable(task.load): + raise TaskValidationError(f"task '{task.name}'.load is not callable") - seen_names.add(task.name) + run = task.load() + if not callable(run): + raise TaskValidationError( + f"task '{task.name}' is missing required run() function" + ) - if not hasattr(task, "run"): - raise TaskValidationError(f"task '{task.name}' is missing run()") + _validate_task_run_signature(name=task.name, run=run) + _validate_task_detail_docstring(name=task.name, run=run) - if not callable(task.run): - raise TaskValidationError(f"task '{task.name}'.run is not callable") + except Exception as exc: + errors.append(f"{task.name} ({task.source}): {exc}") + + return errors - _validate_task_run_signature(task) - _validate_task_detail_docstring(task) - except TaskValidationError as exc: - errors.append(str(exc)) +def task_catalog_ambiguity_errors( + provider_catalogs: Mapping[str, ComponentCatalog[Callable]], + *, + task_names: set[str] | None = None, +) -> list[str]: + """Return provider-scoped task ambiguity errors.""" + errors: list[str] = [] + for provider_name, catalog in sorted(provider_catalogs.items()): + for name, candidates in catalog.inventory.items(): + if task_names is not None and name not in task_names: + continue + if len(candidates) < 2: + continue + + sources = ", ".join(str(candidate.source) for candidate in candidates) + errors.append( + f"task '{name}' is ambiguous for provider '{provider_name}'; " + f"found in multiple sources: {sources}" + ) return errors -def _validate_task_run_signature(task) -> None: +def _validate_task_run_signature(*, name: str, run: Callable) -> None: try: - sig = signature(task.run) - except (TypeError, ValueError) as exc: + validate_keyword_only_invocation( + run, keyword_names=TaskCallContext.keyword_names() + ) + except ValueError as exc: raise TaskValidationError( - f"unable to inspect run() signature for task '{task.name}'" + f"task '{name}' has incompatible run() signature: {exc}" ) from exc - parameters = sig.parameters - - accepts_extra_kwargs = any( - param.kind is Parameter.VAR_KEYWORD for param in parameters.values() - ) - parameter_names = set(parameters) - missing = REQUIRED_RUN_KWARGS - parameter_names - if missing and not accepts_extra_kwargs: - raise TaskValidationError( - f"task '{task.name}' is missing required run() parameters: " - f"{sorted(missing)}" - ) - - for param in parameters.values(): - if param.kind is Parameter.POSITIONAL_ONLY: - raise TaskValidationError( - f"task '{task.name}' uses positional-only parameter " - f"'{param.name}', which is not supported" - ) - -def _validate_task_detail_docstring(task) -> None: - doc = getdoc(task.run) +def _validate_task_detail_docstring(*, name: str, run: Callable) -> None: + doc = getdoc(run) if doc is None: - module = getmodule(task.run) + module = getmodule(run) if module is not None: doc = getdoc(module) if doc is None: raise TaskValidationError( - f"task '{task.name}' is missing detail documentation; add a " + f"task '{name}' is missing detail documentation; add a " "Google-style run() docstring for 'anvil list --tasks --detail'" ) diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py index 52ea681..982752a 100644 --- a/tests/cli/test_cli_smoke.py +++ b/tests/cli/test_cli_smoke.py @@ -537,7 +537,6 @@ def fake_run_processors(*, specs, context): monkeypatch.setattr(processor_loader, "run_processors", fake_run_processors) processor_loader.run_configured_post_processors( - config_branch=loaded_config.branch, targets=loaded_config.targets, target_results=engine_result.target_results, run_dir=written_results.run_dir, @@ -587,7 +586,6 @@ def fake_run_processors(**kwargs): monkeypatch.setattr(processor_loader, "run_processors", fake_run_processors) processor_loader.run_configured_post_processors( - config_branch=loaded_config.branch, targets=loaded_config.targets, target_results=engine_result.target_results, run_dir=written_results.run_dir, @@ -638,7 +636,6 @@ def fake_run_processors(*, specs, context): monkeypatch.setattr(processor_loader, "run_processors", fake_run_processors) processor_loader.run_configured_post_processors( - config_branch=loaded_config.branch, targets=loaded_config.targets, target_results=[target_result], run_dir=written_results.run_dir, diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index 6785fef..c653645 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -5,7 +5,7 @@ import pytest -from anvil._components import ComponentOrigin, ComponentSource +from anvil._components import ComponentCatalog, ComponentOrigin, ComponentSource from anvil.providers.base import ProviderMetadata @@ -162,7 +162,6 @@ def fake_task_validation_errors(tasks): cli._validate_selected_tasks([]) - assert seen["loaded"] == ["count_vpc", "noop"] assert seen["validated"] == ["count_vpc", "noop"] @@ -205,7 +204,6 @@ def fake_task_validation_errors(tasks): cli._validate_selected_tasks(["noop"]) - assert seen["loaded"] == ["noop"] assert seen["validated"] == ["noop"] @@ -215,21 +213,25 @@ def test_validate_all_tasks_reports_duplicate_provider_task_names(monkeypatch): def valid_run(**kwargs): return None + descriptors = [ + cli.TaskDescriptor( + name="shared", load=lambda: valid_run, source=_source("universal") + ), + cli.TaskDescriptor( + name="shared", load=lambda: valid_run, source=_source("aws") + ), + ] monkeypatch.setattr( cli, "discover_tasks", lambda: SimpleNamespace( - tasks=[ - cli.TaskDescriptor( - name="shared", load=lambda: valid_run, source="universal" - ), - cli.TaskDescriptor(name="shared", load=lambda: valid_run, source="aws"), - ], + tasks=descriptors, issues=[], + provider_catalogs={"aws": ComponentCatalog.build(descriptors)}, ), ) - with pytest.raises(ValueError, match="duplicate task name: shared"): + with pytest.raises(ValueError, match="ambiguous for provider 'aws'"): cli._validate_selected_tasks([]) @@ -239,22 +241,28 @@ def test_validate_selected_tasks_reports_duplicate_provider_task_names(monkeypat def valid_run(**kwargs): return None + shared_descriptors = [ + cli.TaskDescriptor( + name="shared", load=lambda: valid_run, source=_source("universal") + ), + cli.TaskDescriptor( + name="shared", load=lambda: valid_run, source=_source("aws") + ), + ] monkeypatch.setattr( cli, "discover_tasks", lambda: SimpleNamespace( tasks=[ - cli.TaskDescriptor( - name="shared", load=lambda: valid_run, source="universal" - ), - cli.TaskDescriptor(name="shared", load=lambda: valid_run, source="aws"), + *shared_descriptors, cli.TaskDescriptor(name="other", load=lambda: valid_run, source="aws"), ], issues=[], + provider_catalogs={"aws": ComponentCatalog.build(shared_descriptors)}, ), ) - with pytest.raises(ValueError, match="duplicate task name: shared"): + with pytest.raises(ValueError, match="ambiguous for provider 'aws'"): cli._validate_selected_tasks(["shared"]) @@ -467,6 +475,8 @@ def test_validate_selected_known_processor_ignores_unrelated_discovery_issues( cli = _import_cli_or_skip() def run(*, context, output, metadata): + """Run a known processor.""" + return None monkeypatch.setattr( diff --git a/tests/processors/test_processors.py b/tests/processors/test_processors.py index a530435..789bfed 100644 --- a/tests/processors/test_processors.py +++ b/tests/processors/test_processors.py @@ -5,7 +5,6 @@ import pytest -from anvil.descriptors import ConfigBranch from anvil.processor_loader import ( ProcessorDescriptor, ProcessorRunContext, @@ -19,7 +18,6 @@ def _context(tmp_path: Path) -> ProcessorRunContext: return ProcessorRunContext( - config_branch=ConfigBranch.TARGETS, run_dir=tmp_path, summary_path=tmp_path / "summary.json", summary={"state": "completed_success"}, @@ -29,6 +27,8 @@ def _context(tmp_path: Path) -> ProcessorRunContext: def test_validate_processors_accepts_valid_processor(): def run(*, context, output, metadata): + """Run a valid processor.""" + return None validate_processors( @@ -38,6 +38,8 @@ def run(*, context, output, metadata): def test_validate_processors_rejects_duplicate_names(): def run(*, context, output, metadata): + """Run a duplicate processor.""" + return None with pytest.raises(ProcessorValidationError, match="duplicate processor name"): @@ -79,7 +81,7 @@ def test_load_processor_rejects_duplicate_catalog_candidates(monkeypatch): "_processor_catalog", lambda: ComponentCatalog.build(descriptors), ) - processor_loader.load_processor_callable.cache_clear() + processor_loader._clear_processor_caches() with pytest.raises(processor_loader.ProcessorConfigError, match="ambiguous"): processor_loader.load_processor_callable("shared") @@ -87,6 +89,8 @@ def test_load_processor_rejects_duplicate_catalog_candidates(monkeypatch): def test_validate_processors_rejects_missing_contract_parameter(): def run(*, context, output): + """Run an invalid processor.""" + return None with pytest.raises(ProcessorValidationError, match="metadata"): @@ -95,6 +99,40 @@ def run(*, context, output): ) +def test_validate_processors_rejects_additional_required_parameter(): + def run(*, context, output, metadata, extra): + """Run an invalid processor with an unsupplied parameter.""" + + return None + + with pytest.raises(ProcessorValidationError, match="extra"): + validate_processors( + [ProcessorDescriptor(name="summary", load=lambda: run, source="stock")] + ) + + +def test_validate_processors_requires_keyword_only_contract_parameters(): + def run(context, *, output, metadata): + """Run an invalid processor with a positional-or-keyword parameter.""" + + return None + + with pytest.raises(ProcessorValidationError, match="keyword-only"): + validate_processors( + [ProcessorDescriptor(name="summary", load=lambda: run, source="stock")] + ) + + +def test_validate_processors_rejects_missing_detail_docstring(): + def run(*, context, output, metadata): + return None + + with pytest.raises(ProcessorValidationError, match="detail documentation"): + validate_processors( + [ProcessorDescriptor(name="summary", load=lambda: run, source="stock")] + ) + + def test_run_processors_executes_in_declaration_order(monkeypatch, tmp_path): seen: list[tuple[str, str | None, dict[str, object]]] = [] @@ -121,6 +159,72 @@ def second(*, context, output, metadata): assert seen == [("first", "one.md", {"include": True}), ("second", "two.md", {})] +def test_run_processors_isolates_processor_metadata(monkeypatch, tmp_path): + def mutate(*, context, output, metadata): + metadata["changed"] = True + + monkeypatch.setattr( + "anvil.processor_loader.load_processor_callable", lambda processor_name: mutate + ) + spec = ProcessorSpec("mutate", metadata={"original": True}) + + run_processors(specs=[spec], context=_context(tmp_path)) + + assert spec.metadata == {"original": True} + + +def test_run_processors_isolates_top_level_context_data(monkeypatch, tmp_path): + observed_states: list[str] = [] + + def first(*, context, output, metadata): + context.summary["state"] = "mutated" + + def second(*, context, output, metadata): + observed_states.append(str(context.summary["state"])) + + processors = {"first": first, "second": second} + monkeypatch.setattr( + "anvil.processor_loader.load_processor_callable", + lambda processor_name: processors[processor_name], + ) + context = _context(tmp_path) + + run_processors( + specs=[ProcessorSpec("first"), ProcessorSpec("second")], context=context + ) + + assert observed_states == ["completed_success"] + assert context.summary == {"state": "completed_success"} + + +def test_processor_context_derives_selected_result_and_path(tmp_path): + target_path = tmp_path / "targets" / "production.json" + context = ProcessorRunContext( + run_dir=tmp_path, + summary_path=tmp_path / "summary.json", + summary={}, + target_result_paths={"production": target_path}, + target_name="production", + target_metadata={"team": "security"}, + target_results=({"target": "production", "entities": []},), + ) + + assert context.target_result == {"target": "production", "entities": []} + assert context.target_result_path == target_path + + +def test_processor_context_rejects_inconsistent_selected_target(tmp_path): + with pytest.raises(ValueError, match="exactly one matching"): + ProcessorRunContext( + run_dir=tmp_path, + summary_path=tmp_path / "summary.json", + summary={}, + target_result_paths={}, + target_name="production", + target_results=({"target": "sandbox"},), + ) + + def test_load_completed_run_context_reads_current_results_directory(tmp_path): run_dir = tmp_path / "results" / "smoke" / "2026-06-02T120000Z" target_dir = run_dir / "targets" @@ -138,10 +242,9 @@ def test_load_completed_run_context_reads_current_results_directory(tmp_path): context = load_completed_run_context(results_dir=run_dir) - assert context.config_branch is ConfigBranch.TARGETS assert context.summary == {"state": "completed_success"} assert context.target_result_paths == {"production": target_path} - assert context.target_results == [{"target": "production", "entities": []}] + assert context.target_results == ({"target": "production", "entities": []},) def test_load_completed_run_context_allows_missing_summary(tmp_path): @@ -162,7 +265,6 @@ def test_load_completed_run_context_allows_missing_summary(tmp_path): def test_html_report_load_records_scopes_to_context_target_name(tmp_path): context = ProcessorRunContext( - config_branch=ConfigBranch.TARGETS, run_dir=tmp_path, summary_path=tmp_path / "summary.json", summary={"state": "completed_success"}, @@ -203,7 +305,6 @@ def test_html_report_load_records_scopes_to_context_target_name(tmp_path): def test_html_report_load_records_keeps_whole_run_context(tmp_path): context = ProcessorRunContext( - config_branch=ConfigBranch.TARGETS, run_dir=tmp_path, summary_path=tmp_path / "summary.json", summary={"state": "completed_success"}, diff --git a/tests/tasks/test_provider_task_loader.py b/tests/tasks/test_provider_task_loader.py index a892e17..9b7d79a 100644 --- a/tests/tasks/test_provider_task_loader.py +++ b/tests/tasks/test_provider_task_loader.py @@ -5,7 +5,7 @@ import pytest -from anvil._components import ComponentOrigin, ComponentSource +from anvil._components import ComponentCatalog, ComponentOrigin, ComponentSource from anvil.task_loader import TaskConfigError, TaskDescriptor SUPPORTED_TASK_SCOPES = frozenset({"region", "target"}) @@ -141,37 +141,20 @@ def test_duplicate_universal_and_provider_task_name_is_ambiguous(monkeypatch): task_loader = importlib.import_module("anvil.task_loader") _clear_task_loader_caches(task_loader) - monkeypatch.setattr( - task_loader, - "_provider_task_descriptor_index", - lambda provider_name: { - "shared": ( - _descriptor("shared", "universal"), - _descriptor("shared", provider_name), - ) - }, + catalog = ComponentCatalog.build( + [_descriptor("shared", "universal"), _descriptor("shared", "aws")] ) monkeypatch.setattr( - task_loader, - "_provider_task_discovery", - lambda provider_name: ( - { - "shared": ( - _descriptor("shared", "universal"), - _descriptor("shared", provider_name), - ) - }, - (), - ), + task_loader, "_provider_task_catalog", lambda provider_name: catalog ) index = task_loader.provider_task_descriptor_index(provider_name="aws") assert [str(descriptor.source) for descriptor in index["shared"]] == [ - "universal", "aws", + "universal", ] - with pytest.raises(TaskConfigError, match="ambiguous.*universal.*aws"): + with pytest.raises(TaskConfigError, match="ambiguous.*aws.*universal"): task_loader.resolve_tasks( task_specs=[{"name": "shared"}], provider_name="aws", @@ -183,9 +166,9 @@ def test_provider_descriptor_index_adds_provider_package_tasks(monkeypatch): task_loader = importlib.import_module("anvil.task_loader") _clear_task_loader_caches(task_loader) - def fake_package_descriptors(*, package_name, source): - if source == "example": - return [_descriptor("aws_only", source)] + def fake_package_descriptors(*, package_name, source_label, provider_name): + if source_label == "example": + return [_descriptor("aws_only", source_label)] return [] monkeypatch.setattr( @@ -207,9 +190,9 @@ def test_resolve_tasks_accepts_non_aws_provider_name(monkeypatch): task_loader = importlib.import_module("anvil.task_loader") _clear_task_loader_caches(task_loader) - def fake_package_descriptors(*, package_name, source): - if source == "universal": - return [_descriptor("shared_task", source)] + def fake_package_descriptors(*, package_name, source_label, provider_name): + if source_label == "universal": + return [_descriptor("shared_task", source_label)] return [] monkeypatch.setattr( @@ -233,13 +216,13 @@ def test_provider_descriptor_index_builds_once_for_multiple_configured_tasks( _clear_task_loader_caches(task_loader) calls = {"packages": 0} - def fake_package_descriptors(*, package_name, source): + def fake_package_descriptors(*, package_name, source_label, provider_name): calls["packages"] += 1 - if source == "build_once": + if source_label == "build_once": return [ - _descriptor("alpha", source), - _descriptor("beta", source), - _descriptor("gamma", source), + _descriptor("alpha", source_label), + _descriptor("beta", source_label), + _descriptor("gamma", source_label), ] return [] @@ -266,12 +249,39 @@ def test_discover_tasks_includes_github_provider_list(monkeypatch): task_loader = importlib.import_module("anvil.task_loader") seen: list[str] = [] - def fake_discovery(provider_name): + def fake_catalog(provider_name): seen.append(provider_name) - return {}, () + return ComponentCatalog.build([]) - monkeypatch.setattr(task_loader, "_provider_task_discovery", fake_discovery) + monkeypatch.setattr(task_loader, "_provider_task_catalog", fake_catalog) task_loader.discover_tasks() assert seen == ["aws", "azure", "gcp", "github"] + + +def test_discover_tasks_scans_universal_sources_once(monkeypatch): + task_loader = importlib.import_module("anvil.task_loader") + package_sources: list[str] = [] + + def fake_package_descriptors(*, package_name, source_label, provider_name): + package_sources.append(source_label) + return [] + + monkeypatch.setattr( + task_loader, "_iter_package_task_descriptors", fake_package_descriptors + ) + monkeypatch.setattr( + task_loader, "_iter_plugin_task_descriptors", lambda **kwargs: ([], []) + ) + _clear_task_loader_caches(task_loader) + + task_loader.discover_tasks() + + assert package_sources.count("universal") == 1 + assert sorted(source for source in package_sources if source != "universal") == [ + "aws", + "azure", + "gcp", + "github", + ] diff --git a/tests/tasks/test_task_context.py b/tests/tasks/test_task_context.py new file mode 100644 index 0000000..074d58c --- /dev/null +++ b/tests/tasks/test_task_context.py @@ -0,0 +1,35 @@ +from typing import cast + +from anvil.actions import ActionRecorder +from anvil.task_context import TaskCallContext + + +def _context(metadata: dict[str, object]) -> TaskCallContext: + return TaskCallContext( + provider="aws", + execution_target_id="111111111111", + execution_target_name="production", + execution_target_type="account", + region="us-east-1", + session=object(), + dry_run=False, + metadata=metadata, + actions=ActionRecorder(actions=[]), + ) + + +def test_task_context_keyword_names_match_runtime_kwargs(): + context = _context({}) + + assert frozenset(context.to_kwargs()) == TaskCallContext.keyword_names() + + +def test_task_context_returns_isolated_metadata_mapping(): + metadata: dict[str, object] = {"team": "security"} + context = _context(metadata) + + invocation_metadata = cast(dict[str, object], context.to_kwargs()["metadata"]) + invocation_metadata["team"] = "platform" + + assert metadata == {"team": "security"} + assert context.metadata == {"team": "security"} diff --git a/tests/tasks/test_task_loader.py b/tests/tasks/test_task_loader.py index 486ff3d..13a58ea 100644 --- a/tests/tasks/test_task_loader.py +++ b/tests/tasks/test_task_loader.py @@ -2,7 +2,12 @@ import sys from types import ModuleType -from anvil._components import ComponentDescriptor, ComponentOrigin, ComponentSource +from anvil._components import ( + ComponentCatalog, + ComponentDescriptor, + ComponentOrigin, + ComponentSource, +) from anvil.task_loader import ( TaskConfigError, TaskDescriptor, @@ -39,17 +44,16 @@ def _resolve(task_specs): def _mock_provider_tasks(monkeypatch, names: list[str]) -> None: + catalog = ComponentCatalog.build(_descriptor(name) for name in names) + def fake_index(provider_name: str): - return {name: [_descriptor(name)] for name in names} + return { + name: list(descriptors) for name, descriptors in catalog.inventory.items() + } monkeypatch.setattr("anvil.task_loader.provider_task_descriptor_index", fake_index) monkeypatch.setattr( - "anvil.task_loader._provider_task_descriptor_index", - lambda provider_name: {name: (_descriptor(name),) for name in names}, - ) - monkeypatch.setattr( - "anvil.task_loader._provider_task_discovery", - lambda provider_name: ({name: [_descriptor(name)] for name in names}, ()), + "anvil.task_loader._provider_task_catalog", lambda provider_name: catalog ) resolve_tasks.__globals__["_resolve_tasks_cached"].cache_clear() resolve_tasks.__globals__["_load_provider_task_callable"].cache_clear() diff --git a/tests/tasks/test_task_loader_cache.py b/tests/tasks/test_task_loader_cache.py index 3f14078..8282bca 100644 --- a/tests/tasks/test_task_loader_cache.py +++ b/tests/tasks/test_task_loader_cache.py @@ -2,7 +2,7 @@ import importlib -from anvil._components import ComponentOrigin, ComponentSource +from anvil._components import ComponentCatalog, ComponentOrigin, ComponentSource from anvil.task_loader import TaskDescriptor @@ -27,21 +27,9 @@ def run(**kwargs): ), ) + catalog = ComponentCatalog.build([descriptor("alpha"), descriptor("beta")]) monkeypatch.setattr( - task_loader, - "_provider_task_descriptor_index", - lambda provider_name: { - "alpha": (descriptor("alpha"),), - "beta": (descriptor("beta"),), - }, - ) - monkeypatch.setattr( - task_loader, - "_provider_task_discovery", - lambda provider_name: ( - {"alpha": [descriptor("alpha")], "beta": [descriptor("beta")]}, - (), - ), + task_loader, "_provider_task_catalog", lambda provider_name: catalog ) task_loader._load_provider_task_callable.cache_clear() task_loader._resolve_tasks_cached.cache_clear() diff --git a/tests/tasks/test_task_loader_cache_integration.py b/tests/tasks/test_task_loader_cache_integration.py index 52e6856..9986a8f 100644 --- a/tests/tasks/test_task_loader_cache_integration.py +++ b/tests/tasks/test_task_loader_cache_integration.py @@ -2,7 +2,7 @@ import importlib -from anvil._components import ComponentOrigin, ComponentSource +from anvil._components import ComponentCatalog, ComponentOrigin, ComponentSource from anvil.providers.base import ( ProviderAuthResult, ProviderExecutionPlan, @@ -33,12 +33,14 @@ def beta_run(**kwargs): label="test", provider="test", ) - task_index = { - "alpha": (TaskDescriptor("alpha", source, lambda: alpha_run),), - "beta": (TaskDescriptor("beta", source, lambda: beta_run),), - } + task_catalog = ComponentCatalog.build( + [ + TaskDescriptor("alpha", source, lambda: alpha_run), + TaskDescriptor("beta", source, lambda: beta_run), + ] + ) monkeypatch.setattr( - task_loader, "_provider_task_discovery", lambda provider_name: (task_index, ()) + task_loader, "_provider_task_catalog", lambda provider_name: task_catalog ) task_loader._load_provider_task_callable.cache_clear() task_loader._resolve_tasks_cached.cache_clear() diff --git a/tests/tasks/test_task_validation.py b/tests/tasks/test_task_validation.py index 5e8f562..4feaf1f 100644 --- a/tests/tasks/test_task_validation.py +++ b/tests/tasks/test_task_validation.py @@ -1,14 +1,40 @@ +from collections.abc import Callable + import pytest -from anvil.task_loader import ResolvedTask -from anvil.task_validation import TaskValidationError, validate_tasks +from anvil._components import ComponentCatalog, ComponentOrigin, ComponentSource from anvil.providers.azure.tasks.count_resource_groups import ( run as count_resource_groups, ) from anvil.providers.gcp.tasks.get_project_info import run as get_project_info +from anvil.task_loader import TaskDescriptor +from anvil.task_validation import ( + TaskValidationError, + task_catalog_ambiguity_errors, + validate_tasks, +) + + +def _task( + name: str, + run: Callable, + *, + source_label: str = "stock", + provider: str | None = None, +) -> TaskDescriptor: + return TaskDescriptor( + name=name, + source=ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label=source_label, + provider=provider, + ), + load=lambda: run, + ) -def test_validate_tasks_accepts_valid_task(): +def test_validate_tasks_accepts_provider_neutral_task(): def run( *, provider, @@ -25,12 +51,21 @@ def run( pass - task = ResolvedTask(name="valid", run=run, depends_on=[], optional=False) + validate_tasks([_task("valid", run)]) - validate_tasks([task]) +@pytest.mark.parametrize( + ("name", "run"), + [ + ("count_resource_groups", count_resource_groups), + ("get_project_info", get_project_info), + ], +) +def test_validate_tasks_accepts_real_provider_tasks(name, run): + validate_tasks([_task(name, run)]) -def test_validate_tasks_accepts_provider_neutral_task(): + +def test_validate_tasks_rejects_task_missing_actions(): def run( *, provider, @@ -41,37 +76,26 @@ def run( session, dry_run, metadata, - actions, ): - """Run a valid provider-neutral task.""" + """Run an invalid task.""" pass - task = ResolvedTask(name="valid", run=run, depends_on=[], optional=False) - - validate_tasks([task]) + with pytest.raises(TaskValidationError, match="actions"): + validate_tasks([_task("missing-actions", run)]) -def test_validate_tasks_accepts_real_azure_count_resource_groups_task(): - task = ResolvedTask( - name="count_resource_groups", - run=count_resource_groups, - depends_on=[], - optional=False, - ) - - validate_tasks([task]) - +def test_validate_tasks_rejects_bad_signature(): + def run(account_id): + """Run an invalid task.""" -def test_validate_tasks_accepts_real_gcp_get_project_info_task(): - task = ResolvedTask( - name="get_project_info", run=get_project_info, depends_on=[], optional=False - ) + pass - validate_tasks([task]) + with pytest.raises(TaskValidationError): + validate_tasks([_task("bad", run)]) -def test_validate_tasks_rejects_task_missing_actions(): +def test_validate_tasks_rejects_additional_required_parameter(): def run( *, provider, @@ -82,30 +106,39 @@ def run( session, dry_run, metadata, + actions, + extra, ): - """Run an invalid task.""" + """Run an invalid task with an unsupplied parameter.""" pass - task = ResolvedTask(name="missing-actions", run=run, depends_on=[], optional=False) - - with pytest.raises(TaskValidationError): - validate_tasks([task]) + with pytest.raises(TaskValidationError, match="extra"): + validate_tasks([_task("extra", run)]) -def test_validate_tasks_rejects_bad_signature(): - def run(account_id): # missing required kwargs and positional-only shape - """Run an invalid task.""" +def test_validate_tasks_requires_keyword_only_contract_parameters(): + def run( + provider, + *, + execution_target_id, + execution_target_name, + execution_target_type, + region, + session, + dry_run, + metadata, + actions, + ): + """Run an invalid task with a positional-or-keyword parameter.""" pass - task = ResolvedTask(name="bad", run=run, depends_on=[], optional=False) - - with pytest.raises(TaskValidationError): - validate_tasks([task]) + with pytest.raises(TaskValidationError, match="keyword-only"): + validate_tasks([_task("positional", run)]) -def test_validate_tasks_rejects_duplicate_names(): +def test_task_catalog_ambiguity_is_provider_scoped(): def run( *, provider, @@ -122,13 +155,50 @@ def run( pass - tasks = [ - ResolvedTask("dup", run, depends_on=[], optional=False), - ResolvedTask("dup", run, depends_on=[], optional=False), + universal = _task("shared", run, source_label="universal") + aws = _task("shared", run, source_label="aws", provider="aws") + azure = _task("shared", run, source_label="azure", provider="azure") + provider_catalogs = { + "aws": ComponentCatalog.build([universal, aws]), + "azure": ComponentCatalog.build([universal, azure]), + } + + errors = task_catalog_ambiguity_errors(provider_catalogs) + + assert errors == [ + "task 'shared' is ambiguous for provider 'aws'; " + "found in multiple sources: aws, universal", + "task 'shared' is ambiguous for provider 'azure'; " + "found in multiple sources: azure, universal", ] - with pytest.raises(TaskValidationError): - validate_tasks(tasks) + +def test_same_task_name_in_disjoint_provider_catalogs_is_valid(): + def run( + *, + provider, + execution_target_id, + execution_target_name, + execution_target_type, + region, + session, + dry_run, + metadata, + actions, + ): + """Run a provider-specific task.""" + + pass + + aws = _task("audit", run, source_label="aws", provider="aws") + azure = _task("audit", run, source_label="azure", provider="azure") + provider_catalogs = { + "aws": ComponentCatalog.build([aws]), + "azure": ComponentCatalog.build([azure]), + } + + assert task_catalog_ambiguity_errors(provider_catalogs) == [] + validate_tasks([aws, azure]) def test_validate_tasks_rejects_missing_detail_docstring(): @@ -147,9 +217,6 @@ def run( pass run.__doc__ = None - task = ResolvedTask( - name="missing-docstring", run=run, depends_on=[], optional=False - ) with pytest.raises(TaskValidationError, match="detail documentation"): - validate_tasks([task]) + validate_tasks([_task("missing-docstring", run)]) diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index efeda3f..8b7035b 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -74,7 +74,6 @@ def test_package_source_discovers_children_without_importing_them( importlib.invalidate_caches() source = PackageComponentSource( - kind=ComponentKind.TASK, package_name="synthetic_components", source=_source("stock", "synthetic_components"), component_loader=lambda package, name, origin: (package, name, origin.label), @@ -99,7 +98,6 @@ def test_package_source_isolates_broken_package_root( importlib.invalidate_caches() descriptors, issues = PackageComponentSource( - kind=ComponentKind.PROVIDER, package_name="broken_components", source=_source("plugin: broken", "broken_components"), component_loader=lambda package, name, source: object(), diff --git a/tests/test_loader_plugin_entry_points.py b/tests/test_loader_plugin_entry_points.py index 7d14b78..8b54ee9 100644 --- a/tests/test_loader_plugin_entry_points.py +++ b/tests/test_loader_plugin_entry_points.py @@ -154,6 +154,10 @@ def test_provider_specific_plugin_task_resolves_only_for_own_provider( task_specs=[{"name": task_name}], provider_name=provider_name ) assert execution.ordered[0].run() == f"{provider_name}-plugin" + descriptor = task_loader.provider_task_descriptor_index( + provider_name=provider_name + )[task_name][0] + assert descriptor.source.provider == provider_name other_providers = {"aws", "azure", "gcp", "github"} - {provider_name} for other_provider in other_providers: @@ -243,7 +247,7 @@ def test_duplicate_same_distribution_plugin_names_fail_full_task_validation( task_specs=[{"name": "duplicated_plugin_task"}], provider_name="aws" ) - with pytest.raises(ValueError, match="duplicate task name: duplicated_plugin_task"): + with pytest.raises(ValueError, match="ambiguous for provider"): cli._validate_selected_tasks([]) @@ -260,9 +264,7 @@ def test_duplicate_same_distribution_plugin_names_fail_selected_task_validation( importlib.invalidate_caches() _clear_task_loader_caches() - with pytest.raises( - ValueError, match="duplicate task name: selected_duplicate_plugin_task" - ): + with pytest.raises(ValueError, match="ambiguous for provider"): cli._validate_selected_tasks(["selected_duplicate_plugin_task"]) @@ -329,7 +331,7 @@ def test_discover_processors_includes_real_plugin_entry_point(monkeypatch, tmp_p ) monkeypatch.syspath_prepend(str(tmp_path)) importlib.invalidate_caches() - processor_loader.load_processor_callable.cache_clear() + processor_loader._clear_processor_caches() descriptors = processor_loader.discover_processors().processors descriptor = next( From 59606797736829e0a0d5041d6b038869f1e3adac Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:36:39 -0500 Subject: [PATCH 3/8] fix: Remove ConfigBranch from target pipeline Simplifies the config and execution model to schema v2 `targets` only by removing `ConfigBranch` from descriptors, results, runner, CLI, providers, and validators. Result payloads now consistently use `target`/`targets`, branch-specific guards and label mapping were removed, and task execution now calls `task.run(**context.to_kwargs())` directly (deleting the task invocation shim). The provider contract was tightened to require a `preparation` parameter on `resolve_execution_targets`, and tests were updated accordingly (including renaming org-validation tests to target-validation). --- pyproject.toml | 2 +- src/anvil/cli.py | 20 +++--- src/anvil/descriptors.py | 15 +--- src/anvil/providers/aws/provider.py | 7 +- src/anvil/providers/azure/provider.py | 6 +- src/anvil/providers/base.py | 10 ++- src/anvil/providers/gcp/provider.py | 6 +- src/anvil/providers/github/provider.py | 6 +- src/anvil/result_query.py | 14 +--- src/anvil/results.py | 44 +++--------- src/anvil/runner.py | 16 +---- src/anvil/task_invocation.py | 10 --- src/anvil/validators.py | 29 ++++---- tests/cli/test_cli_parallel_targets.py | 19 ++--- tests/cli/test_cli_smoke.py | 26 +++---- tests/cli/test_validate_command.py | 6 +- tests/providers/aws/test_execution_targets.py | 19 +---- tests/providers/aws/test_regions.py | 15 +--- tests/providers/aws/test_runtime.py | 3 +- tests/providers/azure/test_azure_provider.py | 12 +--- tests/providers/gcp/test_gcp_provider.py | 11 +-- .../providers/github/test_github_provider.py | 3 +- tests/providers/test_provider_contract.py | 13 ++++ .../test_provider_owned_target_validation.py | 3 +- tests/results/test_result_query.py | 6 +- tests/results/test_results.py | 3 - tests/runner/test_account_resolver.py | 4 +- tests/runner/test_organization_resolver.py | 3 +- tests/runner/test_provider_execution.py | 3 +- tests/runner/test_runner_auth_pool.py | 4 +- tests/runner/test_runner_flow.py | 3 +- .../test_task_loader_cache_integration.py | 2 - tests/validators/test_config_loading.py | 2 - ...alidation.py => test_target_validation.py} | 69 +++++++------------ 34 files changed, 121 insertions(+), 293 deletions(-) delete mode 100644 src/anvil/task_invocation.py rename tests/validators/{test_org_validation.py => test_target_validation.py} (80%) diff --git a/pyproject.toml b/pyproject.toml index 836fcd7..fdea20f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ testpaths = ["tests"] [tool.coverage.run] omit = [ - "src/anvil/tasks/*", + "src/anvil/providers/tasks/*", ] # Keep release assets empty; update and stage uv.lock without uploading it. diff --git a/src/anvil/cli.py b/src/anvil/cli.py index a933654..252ed34 100644 --- a/src/anvil/cli.py +++ b/src/anvil/cli.py @@ -24,7 +24,7 @@ from anvil._components import DiscoveryIssue as DiscoveryIssue from anvil.benchmark import BenchmarkRecorder -from anvil.descriptors import ConfigBranch, LoadedConfig +from anvil.descriptors import LoadedConfig from anvil.processor_loader import ( ProcessorDescriptor, ProcessorSpec, @@ -100,8 +100,11 @@ class DiagnosticCheck: class ListableDescriptor(Protocol): """Descriptor fields needed for grouped CLI listing.""" - name: str - source: object + @property + def name(self) -> str: ... + + @property + def source(self) -> object: ... class DetailDescriptor(ListableDescriptor, Protocol): @@ -193,12 +196,6 @@ def _create_results_run_dir(*, config_file: Path) -> Path: return run_dir -def _target_results_dir_name(config_branch: ConfigBranch) -> str: - if config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported config branch: {config_branch}") - return "targets" - - def _safe_result_filename(name: str) -> str: safe_name = "".join( character if character.isalnum() or character in {".", "-", "_"} else "_" @@ -223,7 +220,7 @@ def _write_run_results( *, config_file: Path, engine_result: EngineResult ) -> WrittenRunResults: run_dir = _create_results_run_dir(config_file=config_file) - target_results_dir = run_dir / _target_results_dir_name(engine_result.config_branch) + target_results_dir = run_dir / "targets" target_results_dir.mkdir() recorder = BenchmarkRecorder(enabled=engine_result.benchmark is not None) @@ -373,8 +370,7 @@ def _cmd_auth_check(args: argparse.Namespace) -> int: auth_payload: dict[str, str | list[dict[str, object]]] = { "generated_at": engine_result.generated_at, "auth": [ - auth_result.to_dict(config_branch=loaded_config.branch) - for auth_result in engine_result.auth_results + auth_result.to_dict() for auth_result in engine_result.auth_results ], } diff --git a/src/anvil/descriptors.py b/src/anvil/descriptors.py index 2ce3f02..af9921c 100644 --- a/src/anvil/descriptors.py +++ b/src/anvil/descriptors.py @@ -1,11 +1,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import StrEnum - - -class ConfigBranch(StrEnum): - TARGETS = "targets" @dataclass(frozen=True, slots=True) @@ -16,7 +11,6 @@ class TargetDescriptor: Public configs load from schema_version: 2 top-level targets. """ - config_branch: ConfigBranch name: str provider: str mode: str @@ -72,12 +66,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "include", normalized_include) object.__setattr__(self, "exclude", normalized_exclude) - if self.config_branch is ConfigBranch.TARGETS: - if self.include is not None and self.exclude is not None: - raise ValueError("include and exclude cannot both be set") - return - - raise ValueError(f"Unsupported config branch: {self.config_branch}") + if self.include is not None and self.exclude is not None: + raise ValueError("include and exclude cannot both be set") def _validate_provider_options(self) -> None: if not isinstance(self.provider_options, dict): @@ -161,6 +151,5 @@ def _normalize_post_run( @dataclass(frozen=True, slots=True) class LoadedConfig: - branch: ConfigBranch targets: list[TargetDescriptor] max_parallel_targets: int = 1 diff --git a/src/anvil/providers/aws/provider.py b/src/anvil/providers/aws/provider.py index 9f4900f..f6ab95b 100644 --- a/src/anvil/providers/aws/provider.py +++ b/src/anvil/providers/aws/provider.py @@ -5,7 +5,7 @@ from boto3.session import Session from anvil.benchmark import BenchmarkRecorder -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.aws.account import ( Account, @@ -159,10 +159,7 @@ def __init__(self, *, region_service: AwsRegionService | None = None) -> None: self._region_service = region_service or AwsRegionService() def validate_target(self, target: TargetDescriptor) -> None: - """Validate that the target is one of the existing AWS config branches.""" - - if target.config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported AWS target branch: {target.config_branch}") + """Validate an AWS target descriptor.""" if target.provider != self.metadata.name: raise ValueError("AWS provider supports provider 'aws' targets only") if target.mode not in SUPPORTED_MODES: diff --git a/src/anvil/providers/azure/provider.py b/src/anvil/providers/azure/provider.py index 6b38426..58121f1 100644 --- a/src/anvil/providers/azure/provider.py +++ b/src/anvil/providers/azure/provider.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, replace from anvil.benchmark import BenchmarkRecorder -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -357,10 +357,6 @@ def __init__(self, *, session_factory: AzureSessionFactory | None = None) -> Non def validate_target(self, target: TargetDescriptor) -> None: """Validate Azure support for tenant discovery and explicit subscriptions.""" - if target.config_branch is not ConfigBranch.TARGETS: - raise ValueError( - "Azure provider supports targets config (schema_version: 2) only" - ) if target.provider != self.metadata.name: raise ValueError("Azure provider supports provider 'azure' targets only") if target.mode not in SUPPORTED_MODES: diff --git a/src/anvil/providers/base.py b/src/anvil/providers/base.py index 1d95563..2ce18c5 100644 --- a/src/anvil/providers/base.py +++ b/src/anvil/providers/base.py @@ -132,7 +132,7 @@ class ExecutionTarget: @dataclass(frozen=True, slots=True) class ProviderExecutionPlan: - """Execution targets and provider-owned scheduling metadata.""" + """Resolved provider execution targets.""" execution_targets: list[ExecutionTarget] @@ -254,7 +254,13 @@ def validate_provider_contract(provider: Provider) -> None: "cache", "benchmark", }, - "resolve_execution_targets": {"target", "regions", "include", "exclude"}, + "resolve_execution_targets": { + "target", + "regions", + "include", + "exclude", + "preparation", + }, "prepare_execution_runtime": {"target", "execution_target", "context"}, } for method_name, required_parameters in required_methods.items(): diff --git a/src/anvil/providers/gcp/provider.py b/src/anvil/providers/gcp/provider.py index 518f234..6aa7c5f 100644 --- a/src/anvil/providers/gcp/provider.py +++ b/src/anvil/providers/gcp/provider.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass, replace -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -308,10 +308,6 @@ def __init__(self, *, session_factory: GcpSessionFactory | None = None) -> None: def validate_target(self, target: TargetDescriptor) -> None: """Validate GCP support for organization and explicit project targets.""" - if target.config_branch is not ConfigBranch.TARGETS: - raise ValueError( - "GCP provider supports targets config (schema_version: 2) only" - ) if target.provider != self.metadata.name: raise ValueError("GCP provider supports provider 'gcp' targets only") if target.mode not in SUPPORTED_MODES: diff --git a/src/anvil/providers/github/provider.py b/src/anvil/providers/github/provider.py index c19d165..3140d7d 100644 --- a/src/anvil/providers/github/provider.py +++ b/src/anvil/providers/github/provider.py @@ -17,7 +17,7 @@ from typing import Any from urllib.parse import urlparse -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -1231,10 +1231,6 @@ def __init__(self, *, session_factory: GitHubSessionFactory | None = None) -> No def validate_target(self, target: TargetDescriptor) -> None: """Validate GitHub's first schema v2 target modes.""" - if target.config_branch is not ConfigBranch.TARGETS: - raise ValueError( - "GitHub provider supports targets config (schema_version: 2) only" - ) if target.provider != self.metadata.name: raise ValueError("GitHub provider supports provider 'github' targets only") if target.mode not in SUPPORTED_MODES: diff --git a/src/anvil/result_query.py b/src/anvil/result_query.py index 7889560..9e21af6 100644 --- a/src/anvil/result_query.py +++ b/src/anvil/result_query.py @@ -7,7 +7,7 @@ from dataclasses import replace from pathlib import Path -from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor +from anvil.descriptors import LoadedConfig, TargetDescriptor from anvil.results import EntityResult, TargetResult, TaskResult @@ -68,13 +68,11 @@ def build_jsonl_records_for_target( target_result: TargetResult, *, config_file: Path | None = None ) -> list[dict[str, object]]: """Build flattened entity and task records for a target result.""" - target_type = _target_type(target_result.config_branch) records: list[dict[str, object]] = [] for entity_result in target_result.entities: entity_record = _base_entity_record( target_result=target_result, - target_type=target_type, entity_result=entity_result, config_file=config_file, ) @@ -315,12 +313,6 @@ def format_records_table( ) -def _target_type(config_branch: ConfigBranch) -> str: - if config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported config branch: {config_branch}") - return "target" - - def _timed_status_record(result: EntityResult | TaskResult) -> dict[str, object]: return { "status": result.status.value, @@ -333,13 +325,11 @@ def _timed_status_record(result: EntityResult | TaskResult) -> dict[str, object] def _base_entity_record( *, target_result: TargetResult, - target_type: str, entity_result: EntityResult, config_file: Path | None, ) -> dict[str, object]: record: dict[str, object] = { - "target_type": target_type, - target_type: target_result.target_name, + "target_type": "target", "target": target_result.target_name, "generated_at": target_result.generated_at, "dry_run": target_result.dry_run, diff --git a/src/anvil/results.py b/src/anvil/results.py index a7f55ed..98c24ae 100644 --- a/src/anvil/results.py +++ b/src/anvil/results.py @@ -4,14 +4,6 @@ from dataclasses import dataclass, field from enum import Enum -from anvil.descriptors import ConfigBranch - - -def _result_labels(config_branch: ConfigBranch) -> tuple[str, str]: - if config_branch is not ConfigBranch.TARGETS: - raise ValueError(f"Unsupported config branch: {config_branch}") - return "target", "targets" - class ExecutionStatus(str, Enum): SUCCESS = "success" @@ -88,11 +80,9 @@ def is_success(self) -> bool: def is_error(self) -> bool: return self.status.is_error - def to_dict(self, *, config_branch: ConfigBranch) -> dict[str, object]: - singular_key, _ = _result_labels(config_branch) - + def to_dict(self) -> dict[str, object]: return { - singular_key: self.target_name, + "target": self.target_name, "status": self.status.value, "source": self.source, "started_at": self.started_at, @@ -137,7 +127,6 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True, slots=True) class TargetResult: - config_branch: ConfigBranch target_name: str provider: str generated_at: str @@ -169,10 +158,8 @@ def has_failures(self) -> bool: ) def to_dict(self) -> dict[str, object]: - singular_key, _ = _result_labels(self.config_branch) - payload: dict[str, object] = { - singular_key: self.target_name, + "target": self.target_name, "provider": self.provider, "generated_at": self.generated_at, "dry_run": self.dry_run, @@ -189,7 +176,6 @@ def to_dict(self) -> dict[str, object]: def create( cls, *, - config_branch: ConfigBranch, target_name: str, provider: str, dry_run: bool, @@ -198,7 +184,6 @@ def create( benchmark: dict[str, object] | None = None, ) -> TargetResult: return cls( - config_branch=config_branch, target_name=target_name, provider=provider, generated_at=datetime.datetime.now(datetime.UTC).isoformat(), @@ -215,7 +200,6 @@ class EngineResult: Top-level result container for a full config-driven execution. """ - config_branch: ConfigBranch state: EngineState generated_at: str auth_results: list[AuthResult] @@ -244,16 +228,11 @@ def total_interrupted_entities(self) -> int: ) def to_dict(self) -> dict[str, object]: - _, plural_key = _result_labels(self.config_branch) - payload: dict[str, object] = { "state": self.state.value, "generated_at": self.generated_at, - "auth": [ - auth_result.to_dict(config_branch=self.config_branch) - for auth_result in self.auth_results - ], - plural_key: [ + "auth": [auth_result.to_dict() for auth_result in self.auth_results], + "targets": [ target_result.to_dict() for target_result in self.target_results ], } @@ -266,14 +245,12 @@ def to_dict(self) -> dict[str, object]: def create( cls, *, - config_branch: ConfigBranch, state: EngineState, auth_results: list[AuthResult], target_results: list[TargetResult], benchmark: dict[str, object] | None = None, ) -> EngineResult: return cls( - config_branch=config_branch, state=state, generated_at=datetime.datetime.now(datetime.UTC).isoformat(), auth_results=auth_results, @@ -285,13 +262,8 @@ def build_summary(self) -> dict[str, object]: """ Build a high-level summary of the execution suitable for CLI output. """ - singular_key, plural_key = _result_labels(self.config_branch) - target_summaries: list[dict[str, object]] = [] - auth_results = [ - auth_result.to_dict(config_branch=self.config_branch) - for auth_result in self.auth_results - ] + auth_results = [auth_result.to_dict() for auth_result in self.auth_results] total_failed_entities = 0 total_interrupted_entities = 0 total_failed_tasks = 0 @@ -323,7 +295,7 @@ def build_summary(self) -> dict[str, object]: target_summaries.append( { - singular_key: target_result.target_name, + "target": target_result.target_name, "total_entities": target_result.total_entities, "failed_entities": len(failed_entities), "interrupted_entities": len(interrupted_entities), @@ -342,7 +314,7 @@ def build_summary(self) -> dict[str, object]: "state": self.state.value, "generated_at": self.generated_at, "auth": auth_results, - plural_key: target_summaries, + "targets": target_summaries, "total_failed_entities": total_failed_entities, "total_interrupted_entities": total_interrupted_entities, "total_failed_tasks": total_failed_tasks, diff --git a/src/anvil/runner.py b/src/anvil/runner.py index d30bab0..7e987c6 100644 --- a/src/anvil/runner.py +++ b/src/anvil/runner.py @@ -17,7 +17,7 @@ from anvil.benchmark import BenchmarkRecorder from anvil.actions import ActionRecorder -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.provider_loader import load_provider from anvil.providers.base import ( @@ -38,7 +38,6 @@ TaskResult, ) from anvil.task_context import TaskCallContext -from anvil.task_invocation import invoke_task from anvil.task_loader import ResolvedExecution, ResolvedTask, TaskScope, resolve_tasks __LOGGER__ = logging.getLogger(__name__) @@ -526,7 +525,7 @@ def _execute_provider_region( metadata=context.metadata, actions=actions, ) - result = invoke_task(task.run, context=task_context) + result = task.run(**task_context.to_kwargs()) except Exception as error: task_ended_perf = time.perf_counter() task_ended_at = datetime.datetime.now(datetime.UTC).isoformat() @@ -952,7 +951,6 @@ def _execute_provider_targets( ) return TargetResult.create( - config_branch=target.config_branch, target_name=target.name, provider=target.provider, dry_run=context.dry_run, @@ -996,7 +994,6 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu return TargetExecutionOutcome( index=prepared_target.index, target_result=TargetResult.create( - config_branch=target.config_branch, target_name=target.name, provider=target.provider, dry_run=context.dry_run, @@ -1041,7 +1038,6 @@ def run_prepared_target(*, prepared_target: PreparedTarget) -> TargetExecutionOu ) except Exception as error: target_result = TargetResult.create( - config_branch=target.config_branch, target_name=target.name, provider=target.provider, dry_run=context.dry_run, @@ -1084,9 +1080,6 @@ def run_auth_checks(*, targets: list[TargetDescriptor]) -> EngineResult: """ Run authentication checks only. Does not resolve tasks or execute targets. """ - config_branch: ConfigBranch = ( - targets[0].config_branch if targets else ConfigBranch.TARGETS - ) auth_results: list[AuthResult] = [] auth_cache = AuthCheckCache() @@ -1108,7 +1101,6 @@ def run_auth_checks(*, targets: list[TargetDescriptor]) -> EngineResult: auth_results.append(auth_result) return EngineResult.create( - config_branch=config_branch, state=_engine_state_from_auth_results(auth_results=auth_results), auth_results=auth_results, target_results=[], @@ -1243,9 +1235,6 @@ def run_multiple_targets( cli_exclude: list[str] | None, benchmark_enabled: bool = False, ) -> EngineResult: - config_branch: ConfigBranch = ( - targets[0].config_branch if targets else ConfigBranch.TARGETS - ) recorder = BenchmarkRecorder(enabled=benchmark_enabled) with recorder.phase("run_multiple_targets_seconds"): auth_results, target_results, engine_state = _run_target_pipeline( @@ -1261,7 +1250,6 @@ def run_multiple_targets( ) return EngineResult.create( - config_branch=config_branch, state=engine_state, auth_results=auth_results, target_results=target_results, diff --git a/src/anvil/task_invocation.py b/src/anvil/task_invocation.py deleted file mode 100644 index 9e06960..0000000 --- a/src/anvil/task_invocation.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from anvil.task_context import TaskCallContext - - -def invoke_task(task_run: Callable, *, context: TaskCallContext) -> object: - """Invoke a task with provider-neutral kwargs.""" - - return task_run(**context.to_kwargs()) diff --git a/src/anvil/validators.py b/src/anvil/validators.py index 48769c4..5bc0d6a 100644 --- a/src/anvil/validators.py +++ b/src/anvil/validators.py @@ -8,7 +8,7 @@ from jsonschema import Draft202012Validator from referencing import Registry, Resource -from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor +from anvil.descriptors import LoadedConfig, TargetDescriptor __LOGGER__ = logging.getLogger(__name__) @@ -27,7 +27,7 @@ def _load_schema_file(schema_file: str) -> dict: return json.load(handle) -def _detect_config_branch(config: dict) -> ConfigBranch: +def _validate_config_shape(config: dict) -> None: if config.get("schema_version") != 2: raise ValueError( "Unsupported config schema. Anvil v0.30 requires " @@ -37,8 +37,6 @@ def _detect_config_branch(config: dict) -> ConfigBranch: if "targets" not in config: raise ValueError("schema_version 2 configs must contain top-level 'targets'") - return ConfigBranch.TARGETS - @lru_cache(maxsize=1) def _load_targets_schema() -> dict: @@ -50,13 +48,13 @@ def _format_schema_error_location(*, config: dict, error) -> str: location = ".".join(str(path) for path in path_parts) or "root" if len(path_parts) >= 2 and isinstance(path_parts[1], int): - branch_name = path_parts[0] + collection_name = path_parts[0] entry_index = path_parts[1] - if branch_name not in {branch.value for branch in ConfigBranch}: + if collection_name != "targets": return location - entries = config.get(branch_name, []) + entries = config.get(collection_name, []) if not isinstance(entries, list) or not (0 <= entry_index < len(entries)): return location @@ -68,8 +66,7 @@ def _format_schema_error_location(*, config: dict, error) -> str: if not isinstance(entry_name, str) or not entry_name.strip(): return location - label = {ConfigBranch.TARGETS.value: "target"}[branch_name] - return f"{label} '{entry_name}' ({location})" + return f"target '{entry_name}' ({location})" return location @@ -105,7 +102,7 @@ def validate_config_schema(*, config: dict) -> None: "schema_version: 2 with top-level 'targets'." ) - _detect_config_branch(config) + _validate_config_shape(config) schema = _load_targets_schema() registry = _build_schema_registry() @@ -128,24 +125,22 @@ def load_config_descriptors(*, config: dict) -> LoadedConfig: Assumes schema validation has already succeeded. """ - branch = _detect_config_branch(config) - entries = config[branch.value] + _validate_config_shape(config) + entries = config["targets"] max_parallel_targets = config.get("max_parallel_targets", 1) targets: list[TargetDescriptor] = [] for index, entry in enumerate(entries, start=1): if not isinstance(entry, dict): - raise ValueError(f"{branch.value} entry #{index} must be a mapping") + raise ValueError(f"targets entry #{index} must be a mapping") normalized_entry = _normalize_target_entry(entry=entry, index=index) - targets.append(TargetDescriptor(config_branch=branch, **normalized_entry)) + targets.append(TargetDescriptor(**normalized_entry)) validate_target_descriptors(targets=targets) - return LoadedConfig( - branch=branch, max_parallel_targets=max_parallel_targets, targets=targets - ) + return LoadedConfig(max_parallel_targets=max_parallel_targets, targets=targets) def _normalize_target_entry(*, entry: dict, index: int) -> dict: diff --git a/tests/cli/test_cli_parallel_targets.py b/tests/cli/test_cli_parallel_targets.py index 1ab3275..89d18f7 100644 --- a/tests/cli/test_cli_parallel_targets.py +++ b/tests/cli/test_cli_parallel_targets.py @@ -27,19 +27,12 @@ def test_run_single_config_file_passes_run_controls( ): from pathlib import Path - from anvil.descriptors import ConfigBranch, TargetDescriptor + from anvil.descriptors import TargetDescriptor cli = _import_cli_or_skip() - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="target-a", - provider="aws", - mode="organization", - ) - loaded_config = SimpleNamespace( - branch=ConfigBranch.TARGETS, targets=[target], max_parallel_targets=4 - ) + target = TargetDescriptor(name="target-a", provider="aws", mode="organization") + loaded_config = SimpleNamespace(targets=[target], max_parallel_targets=4) seen = {} monkeypatch.setattr( @@ -74,21 +67,19 @@ def fake_run_multiple_targets(**kwargs): def test_validate_cli_overrides_rejects_explicit_mode_exclude(): - from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor + from anvil.descriptors import LoadedConfig, TargetDescriptor cli = _import_cli_or_skip() loaded_config = LoadedConfig( - branch=ConfigBranch.TARGETS, targets=[ TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="aws-accounts", provider="aws", mode="accounts", provider_options={"role_name": "AuditRole"}, include=["111111111111"], ) - ], + ] ) with pytest.raises(ValueError, match="AWS mode 'accounts'.*--exclude"): diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py index 982752a..11b0b1f 100644 --- a/tests/cli/test_cli_smoke.py +++ b/tests/cli/test_cli_smoke.py @@ -74,8 +74,6 @@ def test_write_run_results_uses_config_stem_and_run_id_directories(monkeypatch): from pathlib import Path from types import SimpleNamespace - from anvil.descriptors import ConfigBranch - cli = _import_cli_or_skip() scratch_dir = (Path("tests") / "_tmp" / "cli-smoke").resolve() run_dir = scratch_dir / "results" / "orgs" / "2026-05-01T120000Z" @@ -85,11 +83,9 @@ def test_write_run_results_uses_config_stem_and_run_id_directories(monkeypatch): jsonl_path = run_dir / "results.jsonl" engine_result = SimpleNamespace( - config_branch=ConfigBranch.TARGETS, benchmark=None, target_results=[ SimpleNamespace( - config_branch=ConfigBranch.TARGETS, target_name="org2", generated_at="2026-04-30T00:00:00+00:00", dry_run=True, @@ -238,14 +234,12 @@ def test_print_failure_followups_uses_results_file_command(capsys, monkeypatch): def test_build_rerun_targets_narrows_entities_regions_and_task_dependencies(): - from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor + from anvil.descriptors import LoadedConfig, TargetDescriptor from anvil.result_query import build_rerun_targets loaded_config = LoadedConfig( - branch=ConfigBranch.TARGETS, targets=[ TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -258,14 +252,13 @@ def test_build_rerun_targets_narrows_entities_regions_and_task_dependencies(): ], ), TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-b", provider="aws", mode="organization", regions=["us-east-1"], tasks=[{"name": "inventory"}], ), - ], + ] ) targets = build_rerun_targets( @@ -498,13 +491,12 @@ def test_run_configured_post_processors_runs_successful_targets(monkeypatch): from pathlib import Path from types import SimpleNamespace - from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor + from anvil.descriptors import LoadedConfig, TargetDescriptor import anvil.processor_loader as processor_loader seen = {} target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -517,7 +509,7 @@ def test_run_configured_post_processors_runs_successful_targets(monkeypatch): } ], ) - loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) + loaded_config = LoadedConfig(targets=[target]) target_result_payload = {"target": "org-a", "provider": "aws"} target_result = SimpleNamespace( target_name="org-a", has_failures=False, to_dict=lambda: target_result_payload @@ -559,17 +551,16 @@ def test_run_configured_post_processors_skips_failed_targets(monkeypatch): from pathlib import Path from types import SimpleNamespace - from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor + from anvil.descriptors import LoadedConfig, TargetDescriptor import anvil.processor_loader as processor_loader target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", post_run=[{"processor": "summary_markdown"}], ) - loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) + loaded_config = LoadedConfig(targets=[target]) engine_result = SimpleNamespace( target_results=[SimpleNamespace(target_name="org-a", has_failures=True)] ) @@ -599,12 +590,11 @@ def test_run_configured_post_processors_runs_failure_opt_in(monkeypatch): from pathlib import Path from types import SimpleNamespace - from anvil.descriptors import ConfigBranch, LoadedConfig, TargetDescriptor + from anvil.descriptors import LoadedConfig, TargetDescriptor import anvil.processor_loader as processor_loader seen = {} target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -617,7 +607,7 @@ def test_run_configured_post_processors_runs_failure_opt_in(monkeypatch): }, ], ) - loaded_config = LoadedConfig(branch=ConfigBranch.TARGETS, targets=[target]) + loaded_config = LoadedConfig(targets=[target]) target_result_payload = {"target": "org-a", "provider": "aws"} target_result = SimpleNamespace( target_name="org-a", has_failures=True, to_dict=lambda: target_result_payload diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index c653645..cb63db6 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -533,7 +533,9 @@ def prepare_target( ): return None - def resolve_execution_targets(self, *, target, regions, include, exclude): + def resolve_execution_targets( + self, *, target, regions, include, exclude, preparation=None + ): return None def prepare_execution_runtime(self, *, target, execution_target, context): @@ -689,7 +691,7 @@ def test_validate_config_file_alone_runs_offline_config_validation(monkeypatch, ) calls = [] - loaded_config = SimpleNamespace(branch=cli.ConfigBranch.TARGETS, targets=[]) + loaded_config = SimpleNamespace(targets=[]) monkeypatch.setattr( cli, "_load_targets_from_config_file", diff --git a/tests/providers/aws/test_execution_targets.py b/tests/providers/aws/test_execution_targets.py index 0d9877f..954207c 100644 --- a/tests/providers/aws/test_execution_targets.py +++ b/tests/providers/aws/test_execution_targets.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.providers.aws.provider import ( AwsExecutionTargetData, AwsPreflightData, @@ -57,7 +57,6 @@ def test_resolve_execution_targets_maps_explicit_assume_role_accounts(monkeypatc "anvil.providers.aws.provider.SessionFactory", lambda: session_factory ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="selected", provider="aws", mode="accounts", @@ -95,7 +94,6 @@ def test_resolve_execution_targets_maps_explicit_direct_profile_account(monkeypa "anvil.providers.aws.provider.SessionFactory", lambda: session_factory ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="current", provider="aws", mode="accounts", @@ -124,7 +122,6 @@ def test_resolve_execution_targets_maps_explicit_assume_role_accounts_with_provi "anvil.providers.aws.provider.SessionFactory", lambda: session_factory ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="selected", provider="aws", mode="accounts", @@ -152,7 +149,6 @@ def test_resolve_execution_targets_maps_organization_accounts_and_execution_key( session_factory = FakeSessionFactory() base_session = BaseSession(profile_name="shared") target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -187,7 +183,6 @@ def test_resolve_execution_targets_maps_organization_accounts_with_provider_opti session_factory = FakeSessionFactory() base_session = BaseSession(profile_name="shared") target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -213,11 +208,7 @@ def test_resolve_execution_targets_maps_organization_accounts_with_provider_opti def test_resolve_execution_targets_preserves_unknown_include_warning(caplog): target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - provider="aws", - mode="organization", - include=["999999999999"], + name="org-a", provider="aws", mode="organization", include=["999999999999"] ) plan = AwsProvider().resolve_execution_targets( @@ -242,11 +233,7 @@ def test_resolve_execution_targets_preserves_unknown_include_warning(caplog): def test_resolve_execution_targets_preserves_unknown_exclude_warning(caplog): target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - provider="aws", - mode="organization", - exclude=["999999999999"], + name="org-a", provider="aws", mode="organization", exclude=["999999999999"] ) plan = AwsProvider().resolve_execution_targets( diff --git a/tests/providers/aws/test_regions.py b/tests/providers/aws/test_regions.py index f5a0ec6..a1fae02 100644 --- a/tests/providers/aws/test_regions.py +++ b/tests/providers/aws/test_regions.py @@ -2,7 +2,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.providers.aws import AwsProvider @@ -50,12 +50,7 @@ def client(self, service_name, **kwargs): def test_aws_provider_metadata_declares_default_region(): - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - provider="aws", - mode="organization", - ) + target = TargetDescriptor(name="org-a", provider="aws", mode="organization") assert target.regions is None assert AwsProvider.metadata.default_regions == ("us-east-1",) @@ -100,11 +95,7 @@ def create_base_session(self, **kwargs): ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org-a", - provider="aws", - mode="organization", - regions=["all"], + name="org-a", provider="aws", mode="organization", regions=["all"] ) regions = AwsProvider().discover_regions(target) diff --git a/tests/providers/aws/test_runtime.py b/tests/providers/aws/test_runtime.py index 2670e6a..6e61a80 100644 --- a/tests/providers/aws/test_runtime.py +++ b/tests/providers/aws/test_runtime.py @@ -10,7 +10,7 @@ MINIMUM_ASSUMED_CREDENTIAL_REFRESH_WINDOW, AccountAccessStrategy, ) -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.aws.provider import AwsExecutionTargetData, AwsProvider from anvil.providers.base import ExecutionTarget @@ -92,7 +92,6 @@ def create_cached_client_session(self, **kwargs): def _target() -> TargetDescriptor: return TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="selected", provider="aws", mode="accounts", diff --git a/tests/providers/azure/test_azure_provider.py b/tests/providers/azure/test_azure_provider.py index 01c4589..d09db76 100644 --- a/tests/providers/azure/test_azure_provider.py +++ b/tests/providers/azure/test_azure_provider.py @@ -8,7 +8,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.azure.provider import ( AzureExecutionTargetData, @@ -115,7 +115,6 @@ def list_locations( def _target(**overrides) -> TargetDescriptor: values = { - "config_branch": ConfigBranch.TARGETS, "name": "azure-subscriptions", "mode": "subscriptions", "provider": "azure", @@ -129,7 +128,6 @@ def _target(**overrides) -> TargetDescriptor: def _raw_target(**overrides): values = { - "config_branch": ConfigBranch.TARGETS, "name": "azure-subscriptions", "mode": "subscriptions", "include": ["sub-a"], @@ -158,12 +156,7 @@ def test_azure_provider_metadata_and_default_locations(): def test_azure_provider_rejects_organization_targets(): provider = AzureProvider() - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="mgmt", - provider="azure", - mode="organization", - ) + target = TargetDescriptor(name="mgmt", provider="azure", mode="organization") with pytest.raises(ValueError, match="Unsupported Azure target mode"): provider.validate_target(target) @@ -482,7 +475,6 @@ def test_azure_tenant_mode_discovers_subscriptions_with_filters(): ) provider = AzureProvider(session_factory=session_factory) target = _target( - config_branch=ConfigBranch.TARGETS, mode="tenant", include=["sub-b"], provider_options={ diff --git a/tests/providers/gcp/test_gcp_provider.py b/tests/providers/gcp/test_gcp_provider.py index a4e1c81..f6535ae 100644 --- a/tests/providers/gcp/test_gcp_provider.py +++ b/tests/providers/gcp/test_gcp_provider.py @@ -8,7 +8,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.gcp.provider import ( GcpExecutionTargetData, @@ -88,7 +88,6 @@ def list_regions( def _target(**overrides) -> TargetDescriptor: values = { - "config_branch": ConfigBranch.TARGETS, "name": "gcp-projects", "provider": "gcp", "mode": "projects", @@ -117,12 +116,7 @@ def test_gcp_provider_metadata_and_default_locations(): def test_gcp_provider_rejects_organization_targets(): provider = GcpProvider() - target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="folder", - provider="gcp", - mode="folders", - ) + target = TargetDescriptor(name="folder", provider="gcp", mode="folders") with pytest.raises(ValueError, match="Unsupported GCP target mode"): provider.validate_target(target) @@ -240,7 +234,6 @@ def test_gcp_project_discovery_resolves_listed_projects(): def test_gcp_organization_mode_reports_deferred_discovery(): provider = GcpProvider(session_factory=FakeSessionFactory()) target = _target( - config_branch=ConfigBranch.TARGETS, mode="organization", include=["project-a"], provider_options={"organization_id": "123456789012"}, diff --git a/tests/providers/github/test_github_provider.py b/tests/providers/github/test_github_provider.py index f174159..380af66 100644 --- a/tests/providers/github/test_github_provider.py +++ b/tests/providers/github/test_github_provider.py @@ -9,7 +9,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ExecutionTarget from anvil.providers.github import create_provider_instance @@ -223,7 +223,6 @@ def load(self) -> dict[str, dict[str, str]]: def _target(**overrides) -> TargetDescriptor: values = { - "config_branch": ConfigBranch.TARGETS, "name": "github-repositories", "provider": "github", "mode": "repositories", diff --git a/tests/providers/test_provider_contract.py b/tests/providers/test_provider_contract.py index fe2579a..a416147 100644 --- a/tests/providers/test_provider_contract.py +++ b/tests/providers/test_provider_contract.py @@ -127,3 +127,16 @@ def resolve_execution_targets(self, *, target, regions, include): with pytest.raises(TypeError, match="resolve_execution_targets.*exclude"): validate_provider_contract(BrokenProvider()) + + +def test_provider_contract_rejects_missing_preparation_parameter(): + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="broken", display_name="Broken", supported_task_scopes=frozenset() + ) + + def resolve_execution_targets(self, *, target, regions, include, exclude): + return None + + with pytest.raises(TypeError, match="resolve_execution_targets.*preparation"): + validate_provider_contract(BrokenProvider()) diff --git a/tests/providers/test_provider_owned_target_validation.py b/tests/providers/test_provider_owned_target_validation.py index acedc25..6345db1 100644 --- a/tests/providers/test_provider_owned_target_validation.py +++ b/tests/providers/test_provider_owned_target_validation.py @@ -2,7 +2,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.providers.aws.provider import AwsProvider from anvil.providers.azure.provider import AzureProvider @@ -16,7 +16,6 @@ def _target( provider_options: dict[str, object] | None = None, ) -> TargetDescriptor: return TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name=f"{provider}-{mode}", provider=provider, mode=mode, diff --git a/tests/results/test_result_query.py b/tests/results/test_result_query.py index 1355860..81d9634 100644 --- a/tests/results/test_result_query.py +++ b/tests/results/test_result_query.py @@ -2,7 +2,6 @@ import json -from anvil.descriptors import ConfigBranch from anvil.result_query import ( ResultFilters, build_jsonl_records_for_target, @@ -39,7 +38,6 @@ def _task_result( def _target_result() -> TargetResult: return TargetResult.create( - config_branch=ConfigBranch.TARGETS, target_name="engineering", provider="aws", dry_run=True, @@ -189,10 +187,8 @@ def test_build_rerun_targets_includes_interrupted_task_dependencies(): from anvil.descriptors import LoadedConfig, TargetDescriptor loaded_config = LoadedConfig( - branch=ConfigBranch.TARGETS, targets=[ TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", @@ -202,7 +198,7 @@ def test_build_rerun_targets_includes_interrupted_task_dependencies(): {"name": "cleanup", "depends_on": ["inventory"]}, ], ) - ], + ] ) targets = build_rerun_targets( diff --git a/tests/results/test_results.py b/tests/results/test_results.py index 0952e1c..1b302e4 100644 --- a/tests/results/test_results.py +++ b/tests/results/test_results.py @@ -1,4 +1,3 @@ -from anvil.descriptors import ConfigBranch from anvil.results import ( EngineResult, EngineState, @@ -25,7 +24,6 @@ def _entity_result(*, entity_id: str, status: ExecutionStatus) -> EntityResult: def test_engine_summary_counts_interrupted_entities() -> None: target_result = TargetResult.create( - config_branch=ConfigBranch.TARGETS, target_name="org-a", provider="aws", dry_run=True, @@ -38,7 +36,6 @@ def test_engine_summary_counts_interrupted_entities() -> None: ], ) engine_result = EngineResult( - config_branch=ConfigBranch.TARGETS, state=EngineState.CANCELLED, generated_at="2026-03-25T00:00:00+00:00", auth_results=[], diff --git a/tests/runner/test_account_resolver.py b/tests/runner/test_account_resolver.py index 45f6dc7..3422f14 100644 --- a/tests/runner/test_account_resolver.py +++ b/tests/runner/test_account_resolver.py @@ -2,7 +2,7 @@ from anvil.providers.aws.account import AccountAccessStrategy from anvil.providers.aws.account_resolver import AccountResolver -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext @@ -17,7 +17,6 @@ def _context() -> ExecutionContext: def test_resolve_accounts_uses_assume_role_strategy_when_role_name_is_configured(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="selected", provider="aws", mode="accounts", @@ -46,7 +45,6 @@ def test_resolve_accounts_uses_assume_role_strategy_when_role_name_is_configured def test_resolve_accounts_uses_direct_profile_strategy_without_role_name(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="current", provider="aws", mode="accounts", diff --git a/tests/runner/test_organization_resolver.py b/tests/runner/test_organization_resolver.py index a9135ea..88b7bd9 100644 --- a/tests/runner/test_organization_resolver.py +++ b/tests/runner/test_organization_resolver.py @@ -2,7 +2,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.aws.account import AccountAccessStrategy from anvil.providers.aws.config import DEFAULT_ORGANIZATION_ROLE_NAME @@ -47,7 +47,6 @@ def _context(*, regions: list[str] | None = None) -> ExecutionContext: def _target(**kwargs) -> TargetDescriptor: return TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", provider="aws", mode="organization", diff --git a/tests/runner/test_provider_execution.py b/tests/runner/test_provider_execution.py index 0328ef6..0ec49f9 100644 --- a/tests/runner/test_provider_execution.py +++ b/tests/runner/test_provider_execution.py @@ -4,7 +4,7 @@ import time from dataclasses import dataclass -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ExecutionTarget, ProviderMetadata from anvil.results import ExecutionStatus @@ -60,7 +60,6 @@ def prepare_execution_runtime( def _target(*, max_workers: int = 1) -> TargetDescriptor: return TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="provider-target", provider="azure", mode="subscriptions", diff --git a/tests/runner/test_runner_auth_pool.py b/tests/runner/test_runner_auth_pool.py index 4074489..e0c4f6c 100644 --- a/tests/runner/test_runner_auth_pool.py +++ b/tests/runner/test_runner_auth_pool.py @@ -6,7 +6,7 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ProviderAuthResult, ProviderMetadata from anvil.results import AuthResult, EngineState, ExecutionStatus, TargetResult @@ -20,7 +20,6 @@ def _target(*, name: str, profile: str, mode: str = "organization") -> TargetDescriptor: return TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name=name, provider="test", mode=mode, @@ -93,7 +92,6 @@ def _outcome(prepared_target: PreparedTarget) -> TargetExecutionOutcome: return TargetExecutionOutcome( index=prepared_target.index, target_result=TargetResult.create( - config_branch=target.config_branch, target_name=target.name, provider=target.provider, dry_run=False, diff --git a/tests/runner/test_runner_flow.py b/tests/runner/test_runner_flow.py index 56688e3..4a90df0 100644 --- a/tests/runner/test_runner_flow.py +++ b/tests/runner/test_runner_flow.py @@ -4,7 +4,7 @@ from collections import deque from types import SimpleNamespace -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext from anvil.providers.base import ( ExecutionTarget, @@ -29,7 +29,6 @@ def _target(**overrides) -> TargetDescriptor: values = { - "config_branch": ConfigBranch.TARGETS, "name": "target-a", "provider": "test", "mode": "fleet", diff --git a/tests/tasks/test_task_loader_cache_integration.py b/tests/tasks/test_task_loader_cache_integration.py index 9986a8f..7fdb1c7 100644 --- a/tests/tasks/test_task_loader_cache_integration.py +++ b/tests/tasks/test_task_loader_cache_integration.py @@ -46,7 +46,6 @@ def beta_run(**kwargs): task_loader._resolve_tasks_cached.cache_clear() target = descriptors.TargetDescriptor( - config_branch=descriptors.ConfigBranch.TARGETS, name="demo-org", provider="test", mode="fleet", @@ -88,7 +87,6 @@ def resolve_execution_targets(self, **kwargs): def fake_execute_provider_targets(*, target, context, execution_targets, **kwargs): observed_tasks.append([task.name for task in context.tasks]) return results.TargetResult.create( - config_branch=target.config_branch, target_name=target.name, provider=target.provider, dry_run=context.dry_run, diff --git a/tests/validators/test_config_loading.py b/tests/validators/test_config_loading.py index 0836da7..a38a7d8 100644 --- a/tests/validators/test_config_loading.py +++ b/tests/validators/test_config_loading.py @@ -211,8 +211,6 @@ def test_schema_accepts_multicloud_target_shapes(): validators.validate_config_schema(config=config) loaded = validators.load_config_descriptors(config=config) - - assert loaded.branch.value == "targets" assert [(target.provider, target.mode) for target in loaded.targets] == [ ("aws", "organization"), ("aws", "accounts"), diff --git a/tests/validators/test_org_validation.py b/tests/validators/test_target_validation.py similarity index 80% rename from tests/validators/test_org_validation.py rename to tests/validators/test_target_validation.py index 515b34a..e74e17c 100644 --- a/tests/validators/test_org_validation.py +++ b/tests/validators/test_target_validation.py @@ -1,19 +1,14 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import TargetDescriptor from anvil.providers.aws.provider import AwsProvider from anvil.providers.azure.provider import AzureProvider from anvil.providers.gcp.provider import GcpProvider from anvil.providers.github.provider import GithubProvider -def _aws_org(**overrides) -> TargetDescriptor: - values = { - "config_branch": ConfigBranch.TARGETS, - "name": "org", - "provider": "aws", - "mode": "organization", - } +def _aws_organization_target(**overrides) -> TargetDescriptor: + values = {"name": "org", "provider": "aws", "mode": "organization"} values.update(overrides) return TargetDescriptor(**values) @@ -24,7 +19,7 @@ def test_duplicate_org_names(): except PermissionError as error: pytest.skip(f"jsonschema package resources unavailable in test env: {error}") - targets = [_aws_org(name="a"), _aws_org(name="a")] + targets = [_aws_organization_target(name="a"), _aws_organization_target(name="a")] with pytest.raises(ValueError): validate_target_descriptors(targets=targets) @@ -35,7 +30,6 @@ def test_accounts_direct_mode_requires_single_account(): ValueError, match="without role_name must include exactly one account ID" ): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="direct-rollout", provider="aws", mode="accounts", @@ -46,7 +40,6 @@ def test_accounts_direct_mode_requires_single_account(): def test_accounts_assume_role_mode_allows_multiple_accounts(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="assume-role-rollout", provider="aws", mode="accounts", @@ -60,7 +53,6 @@ def test_accounts_assume_role_mode_allows_multiple_accounts(): def test_azure_subscription_mode_allows_multiple_target_ids(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="azure-subscriptions", provider="azure", mode="subscriptions", @@ -74,7 +66,6 @@ def test_azure_subscription_mode_allows_multiple_target_ids(): def test_gcp_project_mode_allows_multiple_target_ids(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="gcp-projects", provider="gcp", mode="projects", @@ -88,7 +79,6 @@ def test_gcp_project_mode_allows_multiple_target_ids(): def test_github_organization_mode_allows_multiple_org_logins(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="github-organizations", provider="github", mode="organizations", @@ -102,7 +92,6 @@ def test_github_organization_mode_allows_multiple_org_logins(): def test_github_repository_mode_allows_owner_repo_values(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="github-repositories", provider="github", mode="repositories", @@ -121,10 +110,7 @@ def test_github_repository_mode_allows_owner_repo_values(): def test_github_modes_require_include(): with pytest.raises(ValueError, match="requires include"): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="github-repositories", - provider="github", - mode="repositories", + name="github-repositories", provider="github", mode="repositories" ) GithubProvider().validate_target(descriptor) @@ -136,11 +122,7 @@ def test_unknown_provider_is_rejected_during_component_resolution(): validate_target_descriptors( targets=[ TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="unknown", - provider="do", - mode="custom", - include=["target-a"], + name="unknown", provider="do", mode="custom", include=["target-a"] ) ] ) @@ -149,7 +131,6 @@ def test_unknown_provider_is_rejected_during_component_resolution(): def test_invalid_provider_mode_is_rejected(): with pytest.raises(ValueError, match="Unsupported Azure target mode"): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="azure-subscriptions", provider="azure", mode="projects", @@ -161,7 +142,6 @@ def test_invalid_provider_mode_is_rejected(): def test_invalid_provider_options_are_rejected(): with pytest.raises(ValueError, match="Unsupported provider.options"): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="gcp-projects", provider="gcp", mode="projects", @@ -172,39 +152,39 @@ def test_invalid_provider_options_are_rejected(): def test_max_parallel_regions_defaults_to_one(): - descriptor = _aws_org() + descriptor = _aws_organization_target() assert descriptor.max_parallel_regions == 1 def test_max_parallel_regions_accepts_maximum_value(): - descriptor = _aws_org(max_parallel_regions=4) + descriptor = _aws_organization_target(max_parallel_regions=4) assert descriptor.max_parallel_regions == 4 def test_organization_regions_accepts_all_selector(): - descriptor = _aws_org(regions=["all"]) + descriptor = _aws_organization_target(regions=["all"]) AwsProvider().validate_target(descriptor) assert descriptor.regions == ["all"] def test_organization_regions_accepts_globs_and_explicit_regions(): - descriptor = _aws_org(regions=["us-*", "ca-central-1"]) + descriptor = _aws_organization_target(regions=["us-*", "ca-central-1"]) AwsProvider().validate_target(descriptor) assert descriptor.regions == ["us-*", "ca-central-1"] def test_post_run_defaults_to_empty_list(): - descriptor = _aws_org() + descriptor = _aws_organization_target() assert descriptor.post_run == [] def test_post_run_normalizes_processor_and_metadata(): - descriptor = _aws_org( + descriptor = _aws_organization_target( post_run=[ {"processor": " summary_markdown ", "metadata": {"include_passed": False}} ] @@ -216,7 +196,7 @@ def test_post_run_normalizes_processor_and_metadata(): def test_post_run_normalizes_run_on_failure(): - descriptor = _aws_org( + descriptor = _aws_organization_target( post_run=[{"processor": "html_report", "run_on_failure": True}] ) @@ -227,14 +207,15 @@ def test_post_run_normalizes_run_on_failure(): def test_regions_rejects_all_mixed_with_other_regions(): with pytest.raises(ValueError, match="'all' must be the only region value"): - AwsProvider().validate_target(_aws_org(regions=["all", "us-east-1"])) + AwsProvider().validate_target( + _aws_organization_target(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"): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="group", provider="aws", mode="accounts", @@ -254,12 +235,7 @@ def test_accounts_regions_reject_selectors(regions): ) def test_provider_location_discovery_modes_accept_selectors(provider, mode, include): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="target", - provider=provider, - mode=mode, - include=include, - regions=["us-*"], + name="target", provider=provider, mode=mode, include=include, regions=["us-*"] ) {"azure": AzureProvider(), "gcp": GcpProvider()}[provider].validate_target( @@ -271,7 +247,6 @@ def test_provider_location_discovery_modes_accept_selectors(provider, mode, incl def test_github_repository_regions_reject_selectors(): with pytest.raises(ValueError, match="selectors are not allowed"): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="github-repos", provider="github", mode="repositories", @@ -284,13 +259,15 @@ def test_github_repository_regions_reject_selectors(): @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"): - _aws_org(max_parallel_regions=max_parallel_regions) + _aws_organization_target(max_parallel_regions=max_parallel_regions) def test_fail_fast_warns_when_combined_concurrency_is_high(caplog): from anvil.validators import validate_target_descriptors - target = _aws_org(max_workers=3, max_parallel_regions=4, fail_fast=True) + target = _aws_organization_target( + max_workers=3, max_parallel_regions=4, fail_fast=True + ) validate_target_descriptors(targets=[target]) @@ -300,7 +277,9 @@ def test_fail_fast_warns_when_combined_concurrency_is_high(caplog): def test_fail_fast_does_not_warn_when_combined_concurrency_is_low(caplog): from anvil.validators import validate_target_descriptors - target = _aws_org(max_workers=2, max_parallel_regions=4, fail_fast=True) + target = _aws_organization_target( + max_workers=2, max_parallel_regions=4, fail_fast=True + ) validate_target_descriptors(targets=[target]) From 8ffab0eb8d2fa879daeb8be20661d0e133d1131c Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:45:44 -0500 Subject: [PATCH 4/8] fix: Expose entity metadata in result queries Allow `entity_metadata` in result field selection so result queries can include that data. Also align target validation messaging and tests with the newer entity-region terminology, and clean up the validate command test stub to match the current provider interface. --- src/anvil/result_query.py | 1 + src/anvil/validators.py | 2 +- tests/cli/test_validate_command.py | 3 --- tests/results/test_result_query.py | 6 +++++- tests/validators/test_target_validation.py | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/anvil/result_query.py b/src/anvil/result_query.py index 9e21af6..64c80a0 100644 --- a/src/anvil/result_query.py +++ b/src/anvil/result_query.py @@ -33,6 +33,7 @@ "duration_seconds", "ended_at", "entity_id", + "entity_metadata", "entity_name", "entity_type", "error", diff --git a/src/anvil/validators.py b/src/anvil/validators.py index 5bc0d6a..5f97b3b 100644 --- a/src/anvil/validators.py +++ b/src/anvil/validators.py @@ -188,7 +188,7 @@ def validate_target_descriptors(*, targets: list[TargetDescriptor]) -> None: if target.fail_fast and combined_concurrency > 10: __LOGGER__.warning( f"Target '{target.name}' has fail_fast enabled with " - f"combined account-region concurrency={combined_concurrency} " + f"combined entity-region concurrency={combined_concurrency} " f"(max_workers={target.max_workers}, " f"max_parallel_regions={target.max_parallel_regions})" ) diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index cb63db6..243498c 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -513,9 +513,6 @@ class Provider: def validate_target(self, target): return None - def default_regions(self, target): - return [] - def auth_cache_key(self, target): return None diff --git a/tests/results/test_result_query.py b/tests/results/test_result_query.py index 81d9634..073b777 100644 --- a/tests/results/test_result_query.py +++ b/tests/results/test_result_query.py @@ -225,7 +225,11 @@ def test_build_rerun_targets_includes_interrupted_task_dependencies(): def test_parse_fields_validates_known_fields(): - assert parse_fields("entity_id, region,task") == ["entity_id", "region", "task"] + assert parse_fields("entity_id, entity_metadata,region") == [ + "entity_id", + "entity_metadata", + "region", + ] def test_parse_fields_rejects_unknown_fields(): diff --git a/tests/validators/test_target_validation.py b/tests/validators/test_target_validation.py index e74e17c..847e250 100644 --- a/tests/validators/test_target_validation.py +++ b/tests/validators/test_target_validation.py @@ -13,7 +13,7 @@ def _aws_organization_target(**overrides) -> TargetDescriptor: return TargetDescriptor(**values) -def test_duplicate_org_names(): +def test_duplicate_target_names(): try: from anvil.validators import validate_target_descriptors except PermissionError as error: @@ -271,7 +271,7 @@ def test_fail_fast_warns_when_combined_concurrency_is_high(caplog): validate_target_descriptors(targets=[target]) - assert "combined account-region concurrency=12" in caplog.text + assert "combined entity-region concurrency=12" in caplog.text def test_fail_fast_does_not_warn_when_combined_concurrency_is_low(caplog): @@ -283,4 +283,4 @@ def test_fail_fast_does_not_warn_when_combined_concurrency_is_low(caplog): validate_target_descriptors(targets=[target]) - assert "combined account-region concurrency" not in caplog.text + assert "combined entity-region concurrency" not in caplog.text From 68504ceeccc56a5461ae6b454fdef8645f858995 Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:02:45 -0500 Subject: [PATCH 5/8] fix: Tighten typing for GitHub tasks and tests Adds `tool.ty` configuration to support optional SDK imports and to relax strict type rules in tests, then updates GitHub provider/task code with Protocol-based client/requester typing and safer mapping normalization for REST results. Also improves local typing in benchmark/test helpers and renames the auth test file to remove the space in its filename. --- pyproject.toml | 23 ++++++++++++++ scripts/plot_benchmarks_grouped.py | 14 +++++++-- src/anvil/providers/github/provider.py | 30 ++++++++++++------- src/anvil/providers/github/tasks/_rest.py | 26 ++++++++++++---- .../providers/github/tasks/audit_rulesets.py | 2 +- .../providers/github/tasks/search_code.py | 13 ++++++-- ...t_auth sources.py => test_auth_sources.py} | 0 .../azure/test_count_resource_groups_task.py | 7 +++-- tests/providers/github/test_rest_tasks.py | 7 ++++- tests/providers/test_provider_contract.py | 8 +++-- 10 files changed, 104 insertions(+), 26 deletions(-) rename tests/auth/{test_auth sources.py => test_auth_sources.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index fdea20f..0287e60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,5 +126,28 @@ task-tags = ["TODO", "FIXME", "HACK"] [tool.ruff.format] skip-magic-trailing-comma = true +[tool.ty.analysis] +# Cloud SDKs are imported lazily from optional extras. Matplotlib is used only +# by the optional benchmark chart generator. +allowed-unresolved-imports = [ + "azure.**", + "github", + "google.**", + "matplotlib.**", +] + +[[tool.ty.overrides]] +include = ["tests/**"] + +[tool.ty.overrides.rules] +# Tests intentionally use lightweight structural doubles and heterogeneous +# dictionaries instead of constructing third-party SDK and runtime objects. +invalid-argument-type = "ignore" +unresolved-attribute = "ignore" +unsupported-operator = "ignore" +not-iterable = "ignore" +not-subscriptable = "ignore" +invalid-assignment = "ignore" + [tool.uv] exclude-newer = "1 week" diff --git a/scripts/plot_benchmarks_grouped.py b/scripts/plot_benchmarks_grouped.py index 4e4a9e9..6c79d89 100644 --- a/scripts/plot_benchmarks_grouped.py +++ b/scripts/plot_benchmarks_grouped.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import TypedDict import matplotlib.pyplot as plt @@ -11,7 +12,16 @@ ANVIL = "#7c4dff" BASELINE = "#f97316" -ROWS: list[dict[str, object]] = [ + +class BenchmarkRow(TypedDict): + """One runtime measurement displayed by the benchmark chart.""" + + group: str + region_label: str + minutes: float + + +ROWS: list[BenchmarkRow] = [ { "group": "Sequential orgs\nSequential accounts", "region_label": "1 region", @@ -65,7 +75,7 @@ def speedup(old: float, new: float) -> float: return old / new -def plot_grouped(rows: list[dict[str, object]], *, output_path: Path) -> None: +def plot_grouped(rows: list[BenchmarkRow], *, output_path: Path) -> None: plt.style.use("dark_background") fig, ax = plt.subplots(figsize=(15, 7.5)) diff --git a/src/anvil/providers/github/provider.py b/src/anvil/providers/github/provider.py index 3140d7d..67398ce 100644 --- a/src/anvil/providers/github/provider.py +++ b/src/anvil/providers/github/provider.py @@ -10,11 +10,11 @@ import threading import time import tomllib -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping from dataclasses import dataclass, replace from pathlib import Path from types import ModuleType -from typing import Any +from typing import Any, Protocol, cast from urllib.parse import urlparse from anvil.descriptors import TargetDescriptor @@ -74,6 +74,16 @@ ) +class _GitHubClient(Protocol): + """PyGithub operations used by the cached client wrapper.""" + + def get_repo(self, full_name_or_id: str) -> object: ... + + def get_organization(self, login: str) -> object: ... + + def search_code(self, *, query: str, highlight: bool) -> Iterable[object]: ... + + @dataclass(frozen=True, slots=True) class GithubRepository: """GitHub repository identity discovered from an owner login.""" @@ -201,7 +211,7 @@ class _RateLimitedSearchResults: def __init__( self, *, - results: object, + results: Iterable[object], rate_key: object, rate_gate: GitHubRateGate, page_size: int, @@ -262,7 +272,7 @@ def __init__( rate_key: object | None = None, rate_gate: GitHubRateGate = _GITHUB_RATE_GATE, ) -> None: - self._client = client + self._client = cast(_GitHubClient, client) self._rate_key = rate_key self._rate_gate = rate_gate self._repositories: dict[str, object] = {} @@ -828,7 +838,7 @@ def _resolve_auth_settings( ) def _settings_from_options( - self, *, options: dict[str, object], source: str, fail_on_missing: bool + self, *, options: Mapping[str, object], source: str, fail_on_missing: bool ) -> GitHubAuthSettings: api_url = self._string_option(provider_options=options, option_name="api_url") api_version = self._string_option( @@ -934,7 +944,7 @@ def _default_chain( tried = ", ".join([*GITHUB_FALLBACK_TOKEN_ENVS, ".netrc", "gh auth token"]) raise RuntimeError(f"GitHub authentication failed. Tried: {tried}") - def _private_key(self, *, options: dict[str, object], source: str) -> str: + def _private_key(self, *, options: Mapping[str, object], source: str) -> str: private_key_env = self._string_option( provider_options=options, option_name="private_key_env" ) @@ -983,7 +993,7 @@ def _required_env_token(token_env: str) -> str: return token.strip() @staticmethod - def _has_explicit_auth_options(provider_options: dict[str, object]) -> bool: + def _has_explicit_auth_options(provider_options: Mapping[str, object]) -> bool: return any( option_name in provider_options for option_name in GITHUB_PROFILE_OPTIONS ) @@ -1146,7 +1156,7 @@ def _load_pygithub() -> ModuleType: @staticmethod def _required_string_option( - *, provider_options: dict[str, object], option_name: str, source: str + *, provider_options: Mapping[str, object], option_name: str, source: str ) -> str: option = provider_options.get(option_name) if not isinstance(option, str) or not option.strip(): @@ -1155,7 +1165,7 @@ def _required_string_option( @staticmethod def _required_int_option( - *, provider_options: dict[str, object], option_name: str + *, provider_options: Mapping[str, object], option_name: str ) -> int: option = provider_options.get(option_name) if not isinstance(option, str) or not option.strip(): @@ -1171,7 +1181,7 @@ def _required_int_option( @staticmethod def _string_option( - *, provider_options: dict[str, object], option_name: str + *, provider_options: Mapping[str, object], option_name: str ) -> str | None: option = provider_options.get(option_name) return option if isinstance(option, str) else None diff --git a/src/anvil/providers/github/tasks/_rest.py b/src/anvil/providers/github/tasks/_rest.py index ea78b3a..3e33bc0 100644 --- a/src/anvil/providers/github/tasks/_rest.py +++ b/src/anvil/providers/github/tasks/_rest.py @@ -1,12 +1,21 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Protocol, cast DEFAULT_MAX_RESULTS = 100 DEFAULT_PER_PAGE = 100 REST_HEADERS = {"Accept": "application/vnd.github+json"} +class _GitHubRequester(Protocol): + """Structural type for the PyGithub REST requester.""" + + def requestJsonAndCheck( # noqa: N802 - matches PyGithub's public method + self, method: str, path: str, *args: object, **kwargs: object + ) -> tuple[object, object]: ... + + def metadata_bool( *, task_name: str, metadata: dict[str, object], key: str, default: bool ) -> bool: @@ -127,9 +136,12 @@ def list_rest_items( client = _session_client(session=session) custom = getattr(client, "rest_get_json_pages", None) if callable(custom): + custom_items = _jsonable(custom(path, params=params, max_results=max_results)) + if not isinstance(custom_items, list): + raise RuntimeError(f"GitHub REST endpoint {path} did not return a list") return [ - item - for item in _jsonable(custom(path, params=params, max_results=max_results)) + {str(key): value for key, value in item.items()} + for item in custom_items if isinstance(item, dict) ][:max_results] @@ -143,7 +155,11 @@ def list_rest_items( if not isinstance(data, list): raise RuntimeError(f"GitHub REST endpoint {path} did not return a list") - page_items = [item for item in data if isinstance(item, dict)] + page_items: list[dict[str, object]] = [ + {str(key): value for key, value in item.items()} + for item in data + if isinstance(item, dict) + ] items.extend(page_items) if len(data) < request_params["per_page"]: break @@ -201,7 +217,7 @@ def _session_client(*, session: object) -> object: return client -def _requester(*, client: object) -> object: +def _requester(*, client: object) -> _GitHubRequester: raw_client = getattr(client, "raw_client", client) requester = getattr(raw_client, "requester", None) if requester is None: @@ -212,7 +228,7 @@ def _requester(*, client: object) -> object: raise RuntimeError( "GitHub REST tasks require a PyGithub requester or rest_get_json helper" ) - return requester + return cast(_GitHubRequester, requester) def _jsonable(value: object) -> object: diff --git a/src/anvil/providers/github/tasks/audit_rulesets.py b/src/anvil/providers/github/tasks/audit_rulesets.py index a2282d5..3152977 100644 --- a/src/anvil/providers/github/tasks/audit_rulesets.py +++ b/src/anvil/providers/github/tasks/audit_rulesets.py @@ -50,7 +50,7 @@ def run( includes_parents = metadata_bool( task_name=TASK_NAME, metadata=metadata, key="includes_parents", default=True ) - params = {"includes_parents": includes_parents} + params: dict[str, object] = {"includes_parents": includes_parents} rulesets = list_rest_items( session=session, path=f"/repos/{owner}/{repo}/rulesets", diff --git a/src/anvil/providers/github/tasks/search_code.py b/src/anvil/providers/github/tasks/search_code.py index 3198dbf..6a3d220 100644 --- a/src/anvil/providers/github/tasks/search_code.py +++ b/src/anvil/providers/github/tasks/search_code.py @@ -6,6 +6,7 @@ import logging from collections.abc import Iterable, Mapping +from typing import Protocol, cast from anvil.actions import ActionRecorder @@ -14,6 +15,14 @@ DEFAULT_MAX_RESULTS = 100 +class _SearchClient(Protocol): + """Structural type for GitHub sessions that support code search.""" + + def search_code( + self, query: str, *, highlight: bool = False + ) -> Iterable[object]: ... + + def _metadata_string( *, metadata: dict[str, object], key: str, required: bool = False ) -> str | None: @@ -182,11 +191,11 @@ def _runtime_error_from_provider_error(error: Exception) -> RuntimeError: return RuntimeError(f"GitHub search_code failed: {message}") -def _search_client(session: object) -> object: +def _search_client(session: object) -> _SearchClient: client = getattr(session, "client", None) if client is None or not callable(getattr(client, "search_code", None)): raise RuntimeError("search_code requires a GitHub session with search_code()") - return client + return cast(_SearchClient, client) def run( diff --git a/tests/auth/test_auth sources.py b/tests/auth/test_auth_sources.py similarity index 100% rename from tests/auth/test_auth sources.py rename to tests/auth/test_auth_sources.py diff --git a/tests/providers/azure/test_count_resource_groups_task.py b/tests/providers/azure/test_count_resource_groups_task.py index cd1d377..4d78616 100644 --- a/tests/providers/azure/test_count_resource_groups_task.py +++ b/tests/providers/azure/test_count_resource_groups_task.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import sys from dataclasses import dataclass from types import ModuleType @@ -29,11 +30,11 @@ class FakeAzureSession: class FakeResourceGroups: - def __init__(self, resource_groups: list[object]) -> None: + def __init__(self, resource_groups: builtins.list[object]) -> None: self._resource_groups = resource_groups - def list(self) -> list[object]: - return list(self._resource_groups) + def list(self) -> builtins.list[object]: + return builtins.list(self._resource_groups) class FakeResourceManagementClient: diff --git a/tests/providers/github/test_rest_tasks.py b/tests/providers/github/test_rest_tasks.py index 7321082..9d74f9b 100644 --- a/tests/providers/github/test_rest_tasks.py +++ b/tests/providers/github/test_rest_tasks.py @@ -40,7 +40,12 @@ def rest_get_json_pages( raise response if not isinstance(response, list): raise AssertionError("paginated response must be a list") - return response[:max_results] + items: list[dict[str, object]] = [] + for item in response[:max_results]: + if not isinstance(item, dict): + raise AssertionError("paginated response items must be mappings") + items.append({str(key): value for key, value in item.items()}) + return items @dataclass(frozen=True) diff --git a/tests/providers/test_provider_contract.py b/tests/providers/test_provider_contract.py index a416147..2869964 100644 --- a/tests/providers/test_provider_contract.py +++ b/tests/providers/test_provider_contract.py @@ -122,7 +122,9 @@ class BrokenProvider(_CompleteProvider): name="broken", display_name="Broken", supported_task_scopes=frozenset() ) - def resolve_execution_targets(self, *, target, regions, include): + def resolve_execution_targets( # ty: ignore[invalid-method-override] + self, *, target, regions, include + ): return None with pytest.raises(TypeError, match="resolve_execution_targets.*exclude"): @@ -135,7 +137,9 @@ class BrokenProvider(_CompleteProvider): name="broken", display_name="Broken", supported_task_scopes=frozenset() ) - def resolve_execution_targets(self, *, target, regions, include, exclude): + def resolve_execution_targets( # ty: ignore[invalid-method-override] + self, *, target, regions, include, exclude + ): return None with pytest.raises(TypeError, match="resolve_execution_targets.*preparation"): From 9d261a89d3fc41715decee451a2cd4b37db47f57 Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:20:41 -0500 Subject: [PATCH 6/8] fix: Implemented component discovery caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cached completed provider and processor catalogs with lru_cache(maxsize=1). - Added centralized cache-clearing functions that invalidate both cache layers. - Updated plugin/import-safety tests to clear snapshots explicitly. -Added five regression tests covering single scans, refresh after clearing, and repeated task listing. Measured warm-call improvement: Provider listing: ~1.8 ms → ~0.0003 ms Processor listing: ~1.8 ms → ~0.0007 ms Task listing: ~1.9 ms → ~0.009 ms --- src/anvil/processor_loader.py | 6 ++- src/anvil/provider_loader.py | 10 +++++ tests/processors/test_processors.py | 41 ++++++++++++++++++- tests/providers/github/test_import_safety.py | 1 + tests/providers/test_provider_loader.py | 42 ++++++++++++++++++++ tests/tasks/test_provider_task_loader.py | 21 ++++++++++ 6 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/anvil/processor_loader.py b/src/anvil/processor_loader.py index 634366d..829f8ed 100644 --- a/src/anvil/processor_loader.py +++ b/src/anvil/processor_loader.py @@ -356,7 +356,10 @@ def _processor_catalog_for_entry_points( return ComponentCatalog.build(descriptors, issues) +@lru_cache(maxsize=1) def _processor_catalog() -> ComponentCatalog[Callable]: + """Return the process-local processor discovery snapshot.""" + return _processor_catalog_for_entry_points( tuple(entry_points(group=PROCESSOR_ENTRY_POINT_GROUP)) ) @@ -377,8 +380,9 @@ def load_processor_callable(processor_name: str) -> Callable: def _clear_processor_caches() -> None: - """Clear the processor discovery cache.""" + """Clear processor discovery snapshots and derived catalogs.""" + _processor_catalog.cache_clear() _processor_catalog_for_entry_points.cache_clear() diff --git a/src/anvil/provider_loader.py b/src/anvil/provider_loader.py index 2007339..0ae49c0 100644 --- a/src/anvil/provider_loader.py +++ b/src/anvil/provider_loader.py @@ -130,12 +130,22 @@ def _provider_catalog_for_entry_points( return ComponentCatalog.build(catalog.descriptors, duplicate_issues) +@lru_cache(maxsize=1) def _provider_catalog() -> ComponentCatalog[Provider]: + """Return the process-local provider discovery snapshot.""" + return _provider_catalog_for_entry_points( tuple(entry_points(group=PROVIDER_PACKAGE_ENTRY_POINT_GROUP)) ) +def _clear_provider_caches() -> None: + """Clear provider discovery snapshots and derived catalogs.""" + + _provider_catalog.cache_clear() + _provider_catalog_for_entry_points.cache_clear() + + def discover_providers() -> ProviderDiscoveryResult: """Discover provider folders without constructing providers.""" diff --git a/tests/processors/test_processors.py b/tests/processors/test_processors.py index 789bfed..020e86f 100644 --- a/tests/processors/test_processors.py +++ b/tests/processors/test_processors.py @@ -25,6 +25,45 @@ def _context(tmp_path: Path) -> ProcessorRunContext: ) +def test_processor_catalog_scans_entry_points_once(monkeypatch): + from anvil import processor_loader + + calls = 0 + + def fake_entry_points(*, group): + nonlocal calls + calls += 1 + return [] + + monkeypatch.setattr(processor_loader, "entry_points", fake_entry_points) + processor_loader._clear_processor_caches() + + processor_loader.list_processors() + processor_loader.list_processors() + + assert calls == 1 + + +def test_clear_processor_caches_refreshes_entry_points(monkeypatch): + from anvil import processor_loader + + calls = 0 + + def fake_entry_points(*, group): + nonlocal calls + calls += 1 + return [] + + monkeypatch.setattr(processor_loader, "entry_points", fake_entry_points) + processor_loader._clear_processor_caches() + + processor_loader.list_processors() + processor_loader._clear_processor_caches() + processor_loader.list_processors() + + assert calls == 2 + + def test_validate_processors_accepts_valid_processor(): def run(*, context, output, metadata): """Run a valid processor.""" @@ -76,12 +115,12 @@ def test_load_processor_rejects_duplicate_catalog_candidates(monkeypatch): load=lambda: lambda **kwargs: None, ), ] + processor_loader._clear_processor_caches() monkeypatch.setattr( processor_loader, "_processor_catalog", lambda: ComponentCatalog.build(descriptors), ) - processor_loader._clear_processor_caches() with pytest.raises(processor_loader.ProcessorConfigError, match="ambiguous"): processor_loader.load_processor_callable("shared") diff --git a/tests/providers/github/test_import_safety.py b/tests/providers/github/test_import_safety.py index 5f77dc1..f69e03e 100644 --- a/tests/providers/github/test_import_safety.py +++ b/tests/providers/github/test_import_safety.py @@ -17,6 +17,7 @@ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): monkeypatch.setattr(builtins, "__import__", guarded_import) monkeypatch.setattr(provider_loader, "entry_points", lambda *, group: []) + provider_loader._clear_provider_caches() providers = provider_loader.list_providers() github_descriptor = next( diff --git a/tests/providers/test_provider_loader.py b/tests/providers/test_provider_loader.py index 2d60dbd..4cc9915 100644 --- a/tests/providers/test_provider_loader.py +++ b/tests/providers/test_provider_loader.py @@ -9,6 +9,15 @@ from anvil import provider_loader +@pytest.fixture(autouse=True) +def clear_provider_caches(): + """Isolate provider entry-point snapshots between tests.""" + + provider_loader._clear_provider_caches() + yield + provider_loader._clear_provider_caches() + + def test_list_providers_returns_aws_without_loading_provider(monkeypatch): monkeypatch.setattr(provider_loader, "entry_points", lambda *, group: []) @@ -22,6 +31,39 @@ def test_list_providers_returns_aws_without_loading_provider(monkeypatch): ] +def test_provider_catalog_scans_entry_points_once(monkeypatch): + calls = 0 + + def fake_entry_points(*, group): + nonlocal calls + calls += 1 + return [] + + monkeypatch.setattr(provider_loader, "entry_points", fake_entry_points) + + provider_loader.list_providers() + provider_loader.list_providers() + + assert calls == 1 + + +def test_clear_provider_caches_refreshes_entry_points(monkeypatch): + calls = 0 + + def fake_entry_points(*, group): + nonlocal calls + calls += 1 + return [] + + monkeypatch.setattr(provider_loader, "entry_points", fake_entry_points) + + provider_loader.list_providers() + provider_loader._clear_provider_caches() + provider_loader.list_providers() + + assert calls == 2 + + def test_load_provider_rejects_duplicate_package_candidates(monkeypatch, tmp_path): package_dir = tmp_path / "duplicate_providers" provider_dir = package_dir / "aws" diff --git a/tests/tasks/test_provider_task_loader.py b/tests/tasks/test_provider_task_loader.py index 9b7d79a..7fe8f31 100644 --- a/tests/tasks/test_provider_task_loader.py +++ b/tests/tasks/test_provider_task_loader.py @@ -285,3 +285,24 @@ def fake_package_descriptors(*, package_name, source_label, provider_name): "gcp", "github", ] + + +def test_repeated_list_tasks_reuses_provider_discovery_snapshot(monkeypatch): + from anvil import provider_loader + + task_loader = importlib.import_module("anvil.task_loader") + entry_point_scans = 0 + + def fake_entry_points(*, group): + nonlocal entry_point_scans + entry_point_scans += 1 + return [] + + monkeypatch.setattr(provider_loader, "entry_points", fake_entry_points) + provider_loader._clear_provider_caches() + _clear_task_loader_caches(task_loader) + + task_loader.list_tasks() + task_loader.list_tasks() + + assert entry_point_scans == 1 From cee1ad2d03519421f2639b1eb00b243ed2d4c03b Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:35:43 -0500 Subject: [PATCH 7/8] fix: format in md files --- .../references/dry-run-and-actions.md | 6 ++---- .../references/sarif-detection-tasks.md | 4 +--- .../references/project-layout-and-cli.md | 3 ++- .pre-commit-config.yaml | 4 ++-- examples/Results/README.md | 18 ++++-------------- pyproject.toml | 3 +-- 6 files changed, 12 insertions(+), 26 deletions(-) diff --git a/.agents/skills/anvil-task-builder/references/dry-run-and-actions.md b/.agents/skills/anvil-task-builder/references/dry-run-and-actions.md index f152ecf..7d36f07 100644 --- a/.agents/skills/anvil-task-builder/references/dry-run-and-actions.md +++ b/.agents/skills/anvil-task-builder/references/dry-run-and-actions.md @@ -36,12 +36,10 @@ Prefer explicit type checks over assuming YAML input shape. Validate lists, bool ```python if dry_run: __LOGGER__.info( - f"(dry-run) Would delete IAM user {user_name} in target " - f"{execution_target_id}" + f"(dry-run) Would delete IAM user {user_name} in target {execution_target_id}" ) actions.record( - f"(dry-run) Would delete IAM user {user_name} in target " - f"{execution_target_id}" + f"(dry-run) Would delete IAM user {user_name} in target {execution_target_id}" ) return {"planned": True, "deleted": False, "user_name": user_name} diff --git a/.agents/skills/anvil-task-builder/references/sarif-detection-tasks.md b/.agents/skills/anvil-task-builder/references/sarif-detection-tasks.md index 208dfbe..57e49c4 100644 --- a/.agents/skills/anvil-task-builder/references/sarif-detection-tasks.md +++ b/.agents/skills/anvil-task-builder/references/sarif-detection-tasks.md @@ -69,9 +69,7 @@ Each finding must include: } ], "fingerprint": "stable-rule-account-region-resource-condition", - "properties": { - "resource_name": "example", - }, + "properties": {"resource_name": "example"}, } ``` diff --git a/.agents/skills/python-best-practices/references/project-layout-and-cli.md b/.agents/skills/python-best-practices/references/project-layout-and-cli.md index 892f442..b3f2001 100644 --- a/.agents/skills/python-best-practices/references/project-layout-and-cli.md +++ b/.agents/skills/python-best-practices/references/project-layout-and-cli.md @@ -53,7 +53,8 @@ Ensure the code is clean, maintainable, PEP 8 compliant, and high quality. Avoid: ```python -def run(x,y): return x+y +def run(x, y): + return x + y ``` Prefer: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ef4962..76931c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: d9fca3320346514799461a80b0753eb45d707d46 # 0.11.28 + rev: e3e6ef7d9bda544b2e795782dbd7d2a4fbd7eb6d # 0.11.32 hooks: - id: uv-lock - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # v0.15.21 + rev: cb8c523fd4835aba42af70f4cad5568db4df0b6c # v0.16.0 hooks: # Run the linter. https://docs.astral.sh/ruff/linter/ - id: ruff-check diff --git a/examples/Results/README.md b/examples/Results/README.md index 12daa76..7a7ee80 100644 --- a/examples/Results/README.md +++ b/examples/Results/README.md @@ -88,9 +88,7 @@ __LOGGER__ = logging.getLogger(__name__) def cleanup_user_resources( - iam_client, - user_name: str, - dry_run: bool, + iam_client, user_name: str, dry_run: bool ) -> dict[str, object]: group_results: list[dict[str, object]] = [] access_key_results: list[dict[str, object]] = [] @@ -151,11 +149,7 @@ def run( raise RuntimeError("example_cleanup requires metadata.user_name to be a string") iam = session.client("iam") - return cleanup_user_resources( - iam_client=iam, - user_name=user_name, - dry_run=dry_run, - ) + return cleanup_user_resources(iam_client=iam, user_name=user_name, dry_run=dry_run) ``` The returned value appears in the task result: @@ -211,6 +205,7 @@ Record actions directly inside the required `run()` function for small tasks: ```python from anvil.actions import ActionRecorder + def run( *, account_id: str, @@ -237,12 +232,7 @@ from anvil.actions import ActionRecorder __LOGGER__ = logging.getLogger(__name__) -def cleanup_user( - iam, - user_name: str, - dry_run: bool, - actions: ActionRecorder, -) -> None: +def cleanup_user(iam, user_name: str, dry_run: bool, actions: ActionRecorder) -> None: if dry_run: message = f"(dry-run) Would delete IAM user: {user_name}" __LOGGER__.debug(message) diff --git a/pyproject.toml b/pyproject.toml index 0287e60..bd5a1a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,5 +149,4 @@ not-iterable = "ignore" not-subscriptable = "ignore" invalid-assignment = "ignore" -[tool.uv] -exclude-newer = "1 week" + From 5d60a94e5111ebe05d67b45b537b81862821423c Mon Sep 17 00:00:00 2001 From: JSChronicles <135760373+JSChronicles@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:36:05 -0500 Subject: [PATCH 8/8] Put back exclusion --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd5a1a8..0287e60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,4 +149,5 @@ not-iterable = "ignore" not-subscriptable = "ignore" invalid-assignment = "ignore" - +[tool.uv] +exclude-newer = "1 week"