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/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/.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/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/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 836fcd7..0287e60 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. @@ -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/_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 747241f..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, @@ -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, @@ -53,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__) @@ -95,8 +100,11 @@ class DiagnosticCheck: class ListableDescriptor(Protocol): """Descriptor fields needed for grouped CLI listing.""" - name: str - source: str + @property + def name(self) -> str: ... + + @property + def source(self) -> object: ... class DetailDescriptor(ListableDescriptor, Protocol): @@ -137,19 +145,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: @@ -192,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 "_" @@ -222,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) @@ -336,7 +334,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, @@ -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 ], } @@ -417,12 +413,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 +457,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 +532,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] = [] @@ -558,19 +549,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) @@ -594,12 +579,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..af9921c 100644 --- a/src/anvil/descriptors.py +++ b/src/anvil/descriptors.py @@ -1,50 +1,6 @@ from __future__ import annotations 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" @dataclass(frozen=True, slots=True) @@ -55,11 +11,10 @@ class TargetDescriptor: Public configs load from schema_version: 2 top-level targets. """ - 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 +27,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 +58,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) @@ -168,158 +66,26 @@ 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") - - 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}") + 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): 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 @@ -385,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/execution_context.py b/src/anvil/execution_context.py index 40c9eac..00b7c45 100644 --- a/src/anvil/execution_context.py +++ b/src/anvil/execution_context.py @@ -9,11 +9,10 @@ @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] - 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..829f8ed 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 @@ -11,6 +11,7 @@ from anvil._components import ( ComponentCatalog, + ComponentDescriptor, ComponentKind, ComponentOrigin, ComponentResolver, @@ -19,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__) @@ -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) @@ -66,18 +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: TargetResult | 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: 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) # ============================================================================ @@ -204,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, @@ -231,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, - 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 +325,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, ...], @@ -307,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, @@ -319,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, @@ -330,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)) ) @@ -341,7 +370,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( @@ -351,15 +379,18 @@ def load_processor_callable(processor_name: str) -> Callable: ).load(processor_name) +def _clear_processor_caches() -> None: + """Clear processor discovery snapshots and derived catalogs.""" + + _processor_catalog.cache_clear() + _processor_catalog_for_entry_points.cache_clear() + + 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 +398,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), ) @@ -379,11 +410,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 @@ -414,12 +452,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 c8ad183..0ae49c0 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) @@ -100,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, @@ -116,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, @@ -140,34 +130,28 @@ 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 _display_name(name: str) -> str: - known_initialisms = {"aws": "AWS", "gcp": "GCP", "github": "GitHub"} - return known_initialisms.get(name, name.replace("_", " ").title()) +def _clear_provider_caches() -> None: + """Clear provider discovery snapshots and derived catalogs.""" - -def _public_descriptor(descriptor) -> ProviderDescriptor: - return ProviderDescriptor( - name=descriptor.name, - display_name=_display_name(descriptor.name), - load=descriptor.load, - source=str(descriptor.source), - ) + _provider_catalog.cache_clear() + _provider_catalog_for_entry_points.cache_clear() 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..f6ab95b 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.descriptors import 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.""" @@ -171,12 +159,58 @@ 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: + 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 +242,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 +268,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 +285,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 +308,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 +327,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 +341,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 +393,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 +454,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 +470,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 +480,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..58121f1 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 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.""" @@ -356,12 +357,19 @@ 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: + 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 +385,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 +476,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 +511,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 +559,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 +657,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..2ce18c5 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,17 +125,33 @@ class ExecutionTarget: name: str type: str provider: str + regions: list[str] metadata: dict[str, object] = field(default_factory=dict) provider_data: object | None = None @dataclass(frozen=True, slots=True) class ProviderExecutionPlan: - """Execution targets and provider-owned scheduling metadata.""" + """Resolved provider execution targets.""" 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,10 +242,25 @@ 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"}, - "resolve_execution_targets": {"target", "regions", "include", "exclude"}, + "prepare_target": { + "target", + "context", + "include", + "exclude", + "cache", + "benchmark", + }, + "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 c258f44..6aa7c5f 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 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} @@ -297,19 +308,54 @@ 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: + 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 +377,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 +399,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 +474,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..67398ce 100644 --- a/src/anvil/providers/github/provider.py +++ b/src/anvil/providers/github/provider.py @@ -10,19 +10,14 @@ import threading import time import tomllib -from collections.abc import Callable, Iterator, Mapping -from dataclasses import dataclass +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 ( - ConfigBranch, - MODE_GITHUB_ORGANIZATIONS, - MODE_GITHUB_REPOSITORIES, - TargetDescriptor, -) +from anvil.descriptors import 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" @@ -60,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.""" @@ -187,7 +211,7 @@ class _RateLimitedSearchResults: def __init__( self, *, - results: object, + results: Iterable[object], rate_key: object, rate_gate: GitHubRateGate, page_size: int, @@ -248,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] = {} @@ -814,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( @@ -920,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" ) @@ -969,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 ) @@ -1132,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(): @@ -1141,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(): @@ -1157,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 @@ -1217,12 +1241,20 @@ 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: + 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 supports targets config (schema_version: 2) only" + "GitHub provider.options.profile cannot be combined with inline " + "GitHub auth options" ) - if target.mode not in {MODE_GITHUB_ORGANIZATIONS, MODE_GITHUB_REPOSITORIES}: - raise ValueError(f"Unsupported GitHub target mode: {target.mode}") if not target.include: raise ValueError( f"GitHub mode '{target.mode}' requires include with owner or " @@ -1233,6 +1265,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 +1319,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 +1341,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 +1370,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 +1428,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 +1446,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 +1500,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/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/src/anvil/result_query.py b/src/anvil/result_query.py index 2538697..64c80a0 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 @@ -18,6 +18,7 @@ "target", "entity_id", "entity_name", + "entity_metadata", "entity_type", "region", "task", @@ -25,17 +26,20 @@ ] FIELD_HEADERS = {"record_type": "type"} AVAILABLE_FIELDS = [ + "actions", "config_file", "config_file_resolved", "dry_run", "duration_seconds", "ended_at", "entity_id", + "entity_metadata", "entity_name", "entity_type", "error", "generated_at", "record_type", + "provider", "region", "result", "started_at", @@ -65,13 +69,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, ) @@ -94,6 +96,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), } ) @@ -311,12 +314,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, @@ -329,19 +326,19 @@ 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, "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..98c24ae 100644 --- a/src/anvil/results.py +++ b/src/anvil/results.py @@ -1,17 +1,9 @@ from __future__ import annotations import datetime -from dataclasses import dataclass +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" @@ -56,6 +48,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 +60,7 @@ def to_dict(self) -> dict[str, object]: "duration_seconds": self.duration_seconds, "result": self.result, "error": self.error, + "actions": list(self.actions), } @@ -86,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, @@ -106,6 +98,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 +110,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, @@ -131,8 +127,8 @@ 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 dry_run: bool entities: list[EntityResult] @@ -162,10 +158,9 @@ 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, "total_entities": self.total_entities, @@ -181,16 +176,16 @@ def to_dict(self) -> dict[str, object]: def create( cls, *, - config_branch: ConfigBranch, target_name: str, + provider: str, dry_run: bool, entities: list[EntityResult], error: str | None = None, 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(), dry_run=dry_run, entities=entities, @@ -205,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] @@ -234,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 ], } @@ -256,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, @@ -275,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 @@ -313,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), @@ -332,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 6562db0..7e987c6 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.descriptors import 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,9 +37,7 @@ 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 __LOGGER__ = logging.getLogger(__name__) @@ -128,20 +118,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 +164,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 +173,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 +225,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 +245,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 +273,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) + cache_key = provider.auth_cache_key(target) + if cache_key is None: + return _run_provider_auth_check_for_target(provider=provider, 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 - - 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 +334,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 +350,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 +415,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 +462,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 +473,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 +512,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, @@ -720,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() @@ -732,6 +537,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 +555,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 +615,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 +690,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 +780,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 +787,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 +875,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 @@ -1136,8 +951,8 @@ 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, @@ -1179,8 +994,8 @@ 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, entities=[], error=prepared_target.preflight_error, @@ -1195,40 +1010,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 +1037,9 @@ 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,22 +1073,13 @@ 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: """ 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() @@ -1309,7 +1088,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, ) @@ -1321,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=[], @@ -1348,7 +1127,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 +1145,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 @@ -1456,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( @@ -1474,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/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..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,18 +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, - "task_context": self, + 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_invocation.py b/src/anvil/task_invocation.py deleted file mode 100644 index 58c903b..0000000 --- a/src/anvil/task_invocation.py +++ /dev/null @@ -1,28 +0,0 @@ -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) diff --git a/src/anvil/task_loader.py b/src/anvil/task_loader.py index 8d01862..03a4e03 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 @@ -8,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, @@ -18,6 +20,7 @@ ComponentSource, DiscoveryIssue, PackageComponentSource, + source_from_entry_point, ) UNIVERSAL_TASK_PACKAGE = "anvil.providers.tasks" @@ -46,13 +49,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 +67,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) @@ -76,6 +76,7 @@ class TaskDiscoveryResult: tasks: list[TaskDescriptor] issues: list[DiscoveryIssue] + provider_catalogs: Mapping[str, ComponentCatalog[Callable]] @dataclass(frozen=True, slots=True) @@ -113,183 +114,148 @@ 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, []) + catalog = _provider_task_catalog(provider_name) + descriptors = catalog.inventory.get(task_name, ()) if not descriptors: + issue_detail = "" + if catalog.issues: + issue_lines = "; ".join( + f"{issue.name} ({issue.source}): {issue.error}" + for issue in catalog.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=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) + 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, - 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, ).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( - *, 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, ).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 -@lru_cache(maxsize=16) -def _provider_task_discovery( - provider_name: str, -) -> tuple[dict[str, tuple[TaskDescriptor, ...]], tuple[DiscoveryIssue, ...]]: - catalog_descriptors: list[CatalogDescriptor[Callable]] = [] - public_descriptors: dict[int, TaskDescriptor] = {} +@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): - 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 - - 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:" + descriptors.extend( + _iter_package_task_descriptors( + package_name=UNIVERSAL_TASK_PACKAGE, + source_label="universal", + provider_name=None, ) - 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 + ) + 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) - 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) +@lru_cache(maxsize=16) +def _provider_specific_task_catalog(provider_name: str) -> ComponentCatalog[Callable]: + descriptors: list[CatalogDescriptor[Callable]] = [] + issues: list[DiscoveryIssue] = [] -def _provider_task_descriptor_index( - provider_name: str, -) -> dict[str, tuple[TaskDescriptor, ...]]: - return _provider_task_discovery(provider_name)[0] + 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) + + +@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( @@ -299,15 +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() - cache_clear = getattr(_provider_task_discovery, "cache_clear", None) - if cache_clear is not None: - cache_clear() + _resolve_tasks_cached.cache_clear() + _provider_task_catalog.cache_clear() + _provider_specific_task_catalog.cache_clear() + _universal_task_catalog.cache_clear() # ============================================================================ @@ -318,13 +285,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 +309,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 +380,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 +433,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 +448,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 +481,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 +496,25 @@ 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() + 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 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) + 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), + ) 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/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/src/anvil/validators.py b/src/anvil/validators.py index 75103a2..5f97b3b 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: @@ -177,6 +172,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,12 +181,14 @@ 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: __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/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/auth/test_auth sources.py b/tests/auth/test_auth_sources.py similarity index 94% rename from tests/auth/test_auth sources.py rename to 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/cli/test_cli_parallel_targets.py b/tests/cli/test_cli_parallel_targets.py index 6bd8d90..89d18f7 100644 --- a/tests/cli/test_cli_parallel_targets.py +++ b/tests/cli/test_cli_parallel_targets.py @@ -27,14 +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") - 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( @@ -69,24 +67,22 @@ 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="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..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,15 +234,14 @@ 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", regions=["us-east-1", "us-west-2"], include=["111111111111", "222222222222"], @@ -257,13 +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( @@ -496,14 +491,15 @@ 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", metadata={"team": "security"}, post_run=[ { @@ -513,8 +509,11 @@ 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) + 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 + ) engine_result = SimpleNamespace(target_results=[target_result]) written_results = SimpleNamespace( run_dir=Path("results/orgs/run"), @@ -530,7 +529,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, @@ -545,7 +543,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"} @@ -553,15 +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)] ) @@ -578,7 +577,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, @@ -592,13 +590,14 @@ 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", post_run=[ {"processor": "success_only"}, { @@ -608,8 +607,11 @@ 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) + 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 + ) written_results = SimpleNamespace( run_dir=Path("results/orgs/run"), summary_path=Path("results/orgs/run/summary.json"), @@ -624,7 +626,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, @@ -638,7 +639,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..243498c 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 ComponentCatalog, 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 @@ -149,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"] @@ -192,7 +204,6 @@ def fake_task_validation_errors(tasks): cli._validate_selected_tasks(["noop"]) - assert seen["loaded"] == ["noop"] assert seen["validated"] == ["noop"] @@ -202,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([]) @@ -226,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"]) @@ -454,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( @@ -483,24 +506,33 @@ 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 - def default_regions(self, target): - return [] - def auth_cache_key(self, target): return None 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 resolve_execution_targets(self, *, target, regions, include, exclude): + 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): @@ -516,7 +548,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 +589,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 +604,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 +618,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 +631,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): @@ -655,7 +688,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/processors/test_processors.py b/tests/processors/test_processors.py index a530435..020e86f 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"}, @@ -27,8 +25,49 @@ 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.""" + return None validate_processors( @@ -38,6 +77,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"): @@ -74,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.load_processor_callable.cache_clear() with pytest.raises(processor_loader.ProcessorConfigError, match="ambiguous"): processor_loader.load_processor_callable("shared") @@ -87,6 +128,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 +138,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 +198,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 +281,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 +304,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 +344,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/providers/aws/test_execution_targets.py b/tests/providers/aws/test_execution_targets.py index 9371f79..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,11 +57,10 @@ 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", - profile="tooling", - role_name="SecurityAccessRole", + provider_options={"profile": "tooling", "role_name": "SecurityAccessRole"}, include=["111111111111", "222222222222"], ) @@ -72,7 +71,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", @@ -96,10 +94,10 @@ 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", - profile="dev-admin", + provider_options={"profile": "dev-admin"}, include=["111111111111"], ) @@ -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", @@ -139,7 +136,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", @@ -153,10 +149,10 @@ 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", - profile="shared", + provider_options={"profile": "shared"}, include=["222222222222"], ) @@ -165,12 +161,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" ] @@ -188,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", @@ -201,12 +195,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" ] @@ -215,10 +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", - mode="organization", - include=["999999999999"], + name="org-a", provider="aws", mode="organization", include=["999999999999"] ) plan = AwsProvider().resolve_execution_targets( @@ -226,7 +216,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": { @@ -243,10 +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", - mode="organization", - exclude=["999999999999"], + name="org-a", provider="aws", mode="organization", exclude=["999999999999"] ) plan = AwsProvider().resolve_execution_targets( @@ -254,7 +241,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..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,7 +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") + target = TargetDescriptor(name="org-a", provider="aws", mode="organization") assert target.regions is None assert AwsProvider.metadata.default_regions == ("us-east-1",) @@ -95,7 +95,7 @@ def create_base_session(self, **kwargs): ) target = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="org-a", 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 242f088..6e61a80 100644 --- a/tests/providers/aws/test_runtime.py +++ b/tests/providers/aws/test_runtime.py @@ -6,17 +6,17 @@ import pytest -from anvil.account import ( +from anvil.providers.aws.account import ( 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 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 @@ -92,10 +92,11 @@ def create_cached_client_session(self, **kwargs): 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 +108,6 @@ def _context( ) -> ExecutionContext: return ExecutionContext( regions=regions or ["us-east-1"], - role_name="TestRole", dry_run=True, tasks=tasks or [], metadata={}, @@ -126,11 +126,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..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,10 +115,9 @@ def list_locations( def _target(**overrides) -> TargetDescriptor: values = { - "config_branch": ConfigBranch.TARGETS, "name": "azure-subscriptions", - "provider": "azure", "mode": "subscriptions", + "provider": "azure", "include": ["sub-a"], } values.update(overrides) @@ -129,7 +128,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 +140,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 +156,15 @@ 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(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 +172,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 +195,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 +221,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 +248,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 +268,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 +295,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 +438,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 +452,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] == [ @@ -455,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/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/gcp/test_gcp_provider.py b/tests/providers/gcp/test_gcp_provider.py index 66a3c9d..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", @@ -100,7 +99,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 +116,9 @@ 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(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 +134,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", @@ -236,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 0fb62c0..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", @@ -235,9 +234,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 +262,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 +275,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 +453,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 +476,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/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/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 6e0a6fc..2869964 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,50 @@ 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 [] + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="broken", display_name="Broken", supported_task_scopes=frozenset() + ) - def auth_cache_key(self, target): + def resolve_execution_targets( # ty: ignore[invalid-method-override] + self, *, target, regions, include + ): return None - def auth_check(self, target): - return None + with pytest.raises(TypeError, match="resolve_execution_targets.*exclude"): + validate_provider_contract(BrokenProvider()) - def discover_regions(self, target): - return [] - def resolve_execution_targets(self, *, target, regions, include): - return None +def test_provider_contract_rejects_missing_preparation_parameter(): + class BrokenProvider(_CompleteProvider): + metadata = ProviderMetadata( + name="broken", display_name="Broken", supported_task_scopes=frozenset() + ) - def prepare_execution_runtime(self, *, target, execution_target, context): + def resolve_execution_targets( # ty: ignore[invalid-method-override] + self, *, target, regions, include, exclude + ): return None - with pytest.raises(TypeError, match="resolve_execution_targets.*exclude"): + with pytest.raises(TypeError, match="resolve_execution_targets.*preparation"): validate_provider_contract(BrokenProvider()) diff --git a/tests/providers/test_provider_loader.py b/tests/providers/test_provider_loader.py index 8033bd9..4cc9915 100644 --- a/tests/providers/test_provider_loader.py +++ b/tests/providers/test_provider_loader.py @@ -9,22 +9,61 @@ 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: []) 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"), ] +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" @@ -66,7 +105,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 +152,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..6345db1 --- /dev/null +++ b/tests/providers/test_provider_owned_target_validation.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import pytest + +from anvil.descriptors import 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( + 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..073b777 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,14 +38,16 @@ def _task_result( 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", @@ -186,11 +187,10 @@ 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", regions=["us-east-1", "us-west-2"], tasks=[ @@ -198,7 +198,7 @@ def test_build_rerun_targets_includes_interrupted_task_dependencies(): {"name": "cleanup", "depends_on": ["inventory"]}, ], ) - ], + ] ) targets = build_rerun_targets( @@ -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/results/test_results.py b/tests/results/test_results.py index 1487f16..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, @@ -13,6 +12,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", @@ -23,8 +24,8 @@ 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, entities=[ _entity_result(entity_id="111111111111", status=ExecutionStatus.SUCCESS), @@ -35,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 c11ed99..3422f14 100644 --- a/tests/runner/test_account_resolver.py +++ b/tests/runner/test_account_resolver.py @@ -1,8 +1,8 @@ from __future__ import annotations -from anvil.account import AccountAccessStrategy -from anvil.account_resolver import AccountResolver -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.providers.aws.account import AccountAccessStrategy +from anvil.providers.aws.account_resolver import AccountResolver +from anvil.descriptors import TargetDescriptor from anvil.execution_context import ExecutionContext @@ -11,21 +11,19 @@ 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 +37,18 @@ 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 +59,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..88b7bd9 100644 --- a/tests/runner/test_organization_resolver.py +++ b/tests/runner/test_organization_resolver.py @@ -2,10 +2,11 @@ import pytest -from anvil.descriptors import ConfigBranch, TargetDescriptor +from anvil.descriptors import 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,20 +41,16 @@ 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={} ) 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 +185,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..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 @@ -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 @@ -56,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", @@ -74,6 +77,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 +91,6 @@ def _context( ) -> ExecutionContext: return ExecutionContext( regions=regions or ["region-a"], - role_name=None, dry_run=False, tasks=tasks or [], metadata={}, @@ -378,7 +381,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 +401,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..e0c4f6c 100644 --- a/tests/runner/test_runner_auth_pool.py +++ b/tests/runner/test_runner_auth_pool.py @@ -1,405 +1,271 @@ 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 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( 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( + 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 +273,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 +327,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 +365,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..4a90df0 100644 --- a/tests/runner/test_runner_flow.py +++ b/tests/runner/test_runner_flow.py @@ -1,1705 +1,455 @@ -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.descriptors import 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 = { + "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..7fe8f31 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 ComponentCatalog, 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, ) @@ -127,31 +141,34 @@ 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) + catalog = ComponentCatalog.build( + [_descriptor("shared", "universal"), _descriptor("shared", "aws")] + ) monkeypatch.setattr( - task_loader, - "_provider_task_descriptor_index", - 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 [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") + assert [str(descriptor.source) for descriptor in index["shared"]] == [ + "aws", + "universal", + ] + with pytest.raises(TaskConfigError, match="ambiguous.*aws.*universal"): + 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): 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( @@ -160,7 +177,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" @@ -171,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( @@ -182,7 +201,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" @@ -195,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 [] @@ -217,6 +238,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"] @@ -227,12 +249,60 @@ 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", + ] + + +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 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 1a644b5..13a58ea 100644 --- a/tests/tasks/test_task_loader.py +++ b/tests/tasks/test_task_loader.py @@ -2,6 +2,12 @@ import sys from types import ModuleType +from anvil._components import ( + ComponentCatalog, + ComponentDescriptor, + ComponentOrigin, + ComponentSource, +) from anvil.task_loader import ( TaskConfigError, TaskDescriptor, @@ -17,17 +23,37 @@ 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: + 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}, + "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() @@ -59,7 +85,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 +113,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 +161,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 +170,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 +179,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 +209,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 +226,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 +241,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..8282bca 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 ComponentCatalog, ComponentOrigin, ComponentSource from anvil.task_loader import TaskDescriptor @@ -15,15 +16,20 @@ 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", + ), + ) + + catalog = ComponentCatalog.build([descriptor("alpha"), descriptor("beta")]) monkeypatch.setattr( - task_loader, - "_provider_task_descriptor_index", - 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() @@ -39,8 +45,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 +74,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 +99,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..7fdb1c7 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 ComponentCatalog, ComponentOrigin, ComponentSource +from anvil.providers.base import ( + ProviderAuthResult, + ProviderExecutionPlan, + ProviderMetadata, + ProviderPreparation, +) from anvil.task_loader import TaskDescriptor @@ -21,76 +27,72 @@ def alpha_run(**kwargs): def beta_run(**kwargs): return "beta" + source = ComponentSource( + origin=ComponentOrigin.STOCK, + package="tests.tasks", + label="test", + provider="test", + ) + task_catalog = ComponentCatalog.build( + [ + TaskDescriptor("alpha", source, lambda: alpha_run), + 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_catalog", lambda provider_name: task_catalog ) task_loader._load_provider_task_callable.cache_clear() task_loader._resolve_tasks_cached.cache_clear() 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 - class FakeResolver: - def __init__(self, *, descriptor, context, **kwargs): - self.descriptor = descriptor - self.context = context + def auth_check(self, target): + return ProviderAuthResult( + status=results.ExecutionStatus.SUCCESS, source="test" + ) - def resolve_accounts(self): - return [] + def prepare_target(self, **kwargs): + return ProviderPreparation() + + 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/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 1014f73..8b54ee9 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,15 +150,19 @@ 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" + 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: 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 +182,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 +216,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,11 +243,11 @@ 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" ) - with pytest.raises(ValueError, match="duplicate task name: duplicated_plugin_task"): + with pytest.raises(ValueError, match="ambiguous for provider"): cli._validate_selected_tasks([]) @@ -256,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"]) @@ -325,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( @@ -334,7 +340,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..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"), @@ -298,7 +296,6 @@ def test_discovery_modes_allow_omitted_include(provider, mode): [ ("aws", "accounts"), ("azure", "subscriptions"), - ("gcp", "projects"), ("github", "organizations"), ("github", "repositories"), ], @@ -306,13 +303,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 +335,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_target_validation.py similarity index 56% rename from tests/validators/test_org_validation.py rename to tests/validators/test_target_validation.py index 306e7f8..847e250 100644 --- a/tests/validators/test_org_validation.py +++ b/tests/validators/test_target_validation.py @@ -1,18 +1,25 @@ 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 test_duplicate_org_names(): +def _aws_organization_target(**overrides) -> TargetDescriptor: + values = {"name": "org", "provider": "aws", "mode": "organization"} + values.update(overrides) + return TargetDescriptor(**values) + + +def test_duplicate_target_names(): try: from anvil.validators import validate_target_descriptors 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_organization_target(name="a"), _aws_organization_target(name="a")] with pytest.raises(ValueError): validate_target_descriptors(targets=targets) @@ -22,27 +29,30 @@ def test_accounts_direct_mode_requires_single_account(): with pytest.raises( ValueError, match="without role_name must include exactly one account ID" ): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, + descriptor = TargetDescriptor( 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"] def test_azure_subscription_mode_allows_multiple_target_ids(): descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, name="azure-subscriptions", provider="azure", mode="subscriptions", @@ -56,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", @@ -70,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", @@ -84,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", @@ -102,136 +109,85 @@ def test_github_repository_mode_allows_owner_repo_values(): def test_github_modes_require_include(): with pytest.raises(ValueError, match="requires include"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="github-repositories", - provider="github", - mode="repositories", + descriptor = TargetDescriptor( + 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( + name="unknown", provider="do", mode="custom", include=["target-a"] + ) + ] ) def test_invalid_provider_mode_is_rejected(): - with pytest.raises(ValueError, match="Unsupported mode"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, + with pytest.raises(ValueError, match="Unsupported Azure target mode"): + descriptor = TargetDescriptor( 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( - config_branch=ConfigBranch.TARGETS, + descriptor = TargetDescriptor( name="gcp-projects", provider="gcp", mode="projects", 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_organization_target() 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_organization_target(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_organization_target(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_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 = TargetDescriptor(config_branch=ConfigBranch.TARGETS, name="org") + descriptor = _aws_organization_target() assert descriptor.post_run == [] def test_post_run_normalizes_processor_and_metadata(): - descriptor = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="org", + descriptor = _aws_organization_target( post_run=[ {"processor": " summary_markdown ", "metadata": {"include_passed": False}} - ], + ] ) assert descriptor.post_run == [ @@ -240,10 +196,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_organization_target( + post_run=[{"processor": "html_report", "run_on_failure": True}] ) assert descriptor.post_run == [ @@ -253,20 +207,22 @@ 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_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"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, + descriptor = TargetDescriptor( name="group", + provider="aws", + mode="accounts", include=["111111111111"], regions=regions, ) + AwsProvider().validate_target(descriptor) @pytest.mark.parametrize( @@ -279,66 +235,52 @@ 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( + descriptor + ) assert descriptor.regions == ["us-*"] def test_github_repository_regions_reject_selectors(): with pytest.raises(ValueError, match="selectors are not allowed"): - TargetDescriptor( - config_branch=ConfigBranch.TARGETS, + descriptor = TargetDescriptor( name="github-repos", provider="github", mode="repositories", 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_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 = TargetDescriptor( - config_branch=ConfigBranch.TARGETS, - name="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]) - 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): 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_organization_target( + max_workers=2, max_parallel_regions=4, fail_fast=True ) validate_target_descriptors(targets=[target]) - assert "combined account-region concurrency" not in caplog.text + assert "combined entity-region concurrency" not in caplog.text