diff --git a/alembic/versions/m6_004_container_metadata_column.py b/alembic/versions/m6_004_container_metadata_column.py index b3343af07..85e4a4386 100644 --- a/alembic/versions/m6_004_container_metadata_column.py +++ b/alembic/versions/m6_004_container_metadata_column.py @@ -8,8 +8,8 @@ Create Date: 2026-03-11 00:00:00 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "m6_004_container_metadata_column" diff --git a/benchmarks/actor_list_empty_bench.py b/benchmarks/actor_list_empty_bench.py index 06c3a5ff0..ee26d4e6c 100644 --- a/benchmarks/actor_list_empty_bench.py +++ b/benchmarks/actor_list_empty_bench.py @@ -14,7 +14,6 @@ from __future__ import annotations import importlib import sys from pathlib import Path -from typing import Any from unittest.mock import MagicMock # Ensure the local *source* tree is importable even when ASV has an @@ -34,10 +33,11 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) +from mocks.fake_provider import FakeProviderInfo, FakeProviderRegistry # noqa: E402 + from cleveragents.actor.registry import ActorRegistry # noqa: E402 from cleveragents.core.exceptions import ValidationError # noqa: E402 from cleveragents.domain.models.core.actor import Actor # noqa: E402 -from mocks.fake_provider import FakeProviderInfo, FakeProviderRegistry # noqa: E402 def _make_registry( diff --git a/benchmarks/automation_profile_cli_bench.py b/benchmarks/automation_profile_cli_bench.py index 45b9a07ce..a7a8043ed 100644 --- a/benchmarks/automation_profile_cli_bench.py +++ b/benchmarks/automation_profile_cli_bench.py @@ -39,7 +39,7 @@ from cleveragents.domain.models.core.automation_profile import ( # noqa: E402 class _InMemoryProfileRepository: - """In-memory repository satisfying the AutomationProfileRepository duck-type contract. + """In-memory repository satisfying the duck-type contract. Provides the four methods expected by ``AutomationProfileService``: ``get_by_name``, ``list_all``, ``upsert``, and ``delete``. diff --git a/benchmarks/bench_detail_level_map.py b/benchmarks/bench_detail_level_map.py index 941589547..e68dca034 100644 --- a/benchmarks/bench_detail_level_map.py +++ b/benchmarks/bench_detail_level_map.py @@ -19,18 +19,18 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) -from cleveragents.acms.uko.detail_level_maps import ( +from cleveragents.acms.uko.detail_level_maps import ( # noqa: E402 CODE_DETAIL_LEVEL_MAP, OO_DETAIL_LEVEL_MAP, DetailLevelMapBuilder, build_effective_map, ) -from cleveragents.acms.uko.vocabularies import ( +from cleveragents.acms.uko.vocabularies import ( # noqa: E402 get_func_vocabulary, get_oo_vocabulary, get_proc_vocabulary, ) -from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry +from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry # noqa: E402 class DetailLevelMapResolutionSuite: diff --git a/benchmarks/bench_metrics_collection.py b/benchmarks/bench_metrics_collection.py index 630b34575..7d1359c85 100644 --- a/benchmarks/bench_metrics_collection.py +++ b/benchmarks/bench_metrics_collection.py @@ -10,7 +10,6 @@ from __future__ import annotations import importlib import sys from pathlib import Path -from unittest.mock import MagicMock _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: @@ -22,7 +21,6 @@ importlib.reload(cleveragents) from cleveragents.domain.models.observability.metrics import ( # noqa: E402 MetricCollector, - MetricType, OperationalMetricKey, ) from cleveragents.infrastructure.observability.metrics_emitter import ( # noqa: E402 diff --git a/benchmarks/bench_plugin_loader.py b/benchmarks/bench_plugin_loader.py index 8b7cd6b42..e9a979d6d 100644 --- a/benchmarks/bench_plugin_loader.py +++ b/benchmarks/bench_plugin_loader.py @@ -25,7 +25,10 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) -from cleveragents.domain.models.acms.backends import TextBackend, TextResult # noqa: E402 +from cleveragents.domain.models.acms.backends import ( # noqa: E402 + TextBackend, + TextResult, +) from cleveragents.infrastructure.plugins.loader import PluginLoader # noqa: E402 from cleveragents.infrastructure.plugins.manager import PluginManager # noqa: E402 from cleveragents.infrastructure.plugins.types import ( # noqa: E402 @@ -34,7 +37,6 @@ from cleveragents.infrastructure.plugins.types import ( # noqa: E402 PluginState, ) - # --------------------------------------------------------------------------- # Helper classes # --------------------------------------------------------------------------- diff --git a/benchmarks/bench_sandbox_strategy.py b/benchmarks/bench_sandbox_strategy.py index b6f47edda..013dd6b30 100644 --- a/benchmarks/bench_sandbox_strategy.py +++ b/benchmarks/bench_sandbox_strategy.py @@ -32,6 +32,8 @@ from cleveragents.domain.models.core.resource import ( # noqa: E402 PhysVirt, Resource, ResourceCapabilities, +) +from cleveragents.domain.models.core.resource import ( # noqa: E402 SandboxStrategy as SandboxStrategyEnum, ) from cleveragents.domain.models.core.sandbox_strategy import ( # noqa: E402 @@ -69,31 +71,31 @@ def _ulid(name: str) -> str: class _MockStrategy: """Minimal strategy satisfying the Protocol for benchmarking.""" - def create(self, plan_id, resource): # noqa: ANN001, ANN201 + def create(self, plan_id, resource): return None - def read(self, ref, path): # noqa: ANN001, ANN201 + def read(self, ref, path): return b"" - def write(self, ref, path, content): # noqa: ANN001, ANN201 + def write(self, ref, path, content): return None - def diff(self, ref): # noqa: ANN001, ANN201 + def diff(self, ref): return None - def commit(self, ref): # noqa: ANN001, ANN201 + def commit(self, ref): pass - def rollback(self, ref): # noqa: ANN001, ANN201 + def rollback(self, ref): pass - def checkpoint(self, ref, checkpoint_id): # noqa: ANN001, ANN201 + def checkpoint(self, ref, checkpoint_id): pass - def restore_checkpoint(self, ref, checkpoint_id): # noqa: ANN001, ANN201 + def restore_checkpoint(self, ref, checkpoint_id): pass - def cleanup(self, ref): # noqa: ANN001, ANN201 + def cleanup(self, ref): pass diff --git a/benchmarks/bench_uko_layer3.py b/benchmarks/bench_uko_layer3.py index d0d11cc40..b0298b0b0 100644 --- a/benchmarks/bench_uko_layer3.py +++ b/benchmarks/bench_uko_layer3.py @@ -20,11 +20,11 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) -from cleveragents.acms.uko.layer3_java import JAVA_DETAIL_LEVELS -from cleveragents.acms.uko.layer3_py import PYTHON_DETAIL_LEVELS -from cleveragents.acms.uko.layer3_rs import RUST_DETAIL_LEVELS -from cleveragents.acms.uko.layer3_ts import TYPESCRIPT_DETAIL_LEVELS -from cleveragents.acms.uko.vocabulary import ( +from cleveragents.acms.uko.layer3_java import JAVA_DETAIL_LEVELS # noqa: E402 +from cleveragents.acms.uko.layer3_py import PYTHON_DETAIL_LEVELS # noqa: E402 +from cleveragents.acms.uko.layer3_rs import RUST_DETAIL_LEVELS # noqa: E402 +from cleveragents.acms.uko.layer3_ts import TYPESCRIPT_DETAIL_LEVELS # noqa: E402 +from cleveragents.acms.uko.vocabulary import ( # noqa: E402 OO_EFFECTIVE_LEVELS, ProvenanceInfo, UKOClass, diff --git a/benchmarks/cloud_resource_bench.py b/benchmarks/cloud_resource_bench.py new file mode 100644 index 000000000..cc1fca28b --- /dev/null +++ b/benchmarks/cloud_resource_bench.py @@ -0,0 +1,196 @@ +"""ASV benchmarks for cloud resource handler overhead. + +Measures the performance of: +- Cloud credential resolution (aws, gcp, azure) +- Cloud credential validation +- CloudResourceHandler instantiation +- CloudSandboxStrategy validation +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +try: + from cleveragents.resource.handlers.cloud import ( + CloudResourceHandler, + CloudSandboxStrategy, + resolve_credentials, + validate_credentials, + ) +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.resource.handlers.cloud import ( + CloudResourceHandler, + CloudSandboxStrategy, + resolve_credentials, + validate_credentials, + ) + + +_CLOUD_ENV_VARS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", + "GCLOUD_PROJECT", + "GCP_REGION", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_REGION", +] + + +def _set_aws_env() -> dict[str, str]: + """Set AWS env vars for benchmarking.""" + saved: dict[str, str] = {} + for var in _CLOUD_ENV_VARS: + val = os.environ.pop(var, None) + if val is not None: + saved[var] = val + os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/EXAMPLEKEY" + os.environ["AWS_REGION"] = "us-east-1" + return saved + + +def _set_gcp_env() -> dict[str, str]: + """Set GCP env vars for benchmarking.""" + saved: dict[str, str] = {} + for var in _CLOUD_ENV_VARS: + val = os.environ.pop(var, None) + if val is not None: + saved[var] = val + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/path/to/sa-key.json" + os.environ["GCLOUD_PROJECT"] = "my-gcp-project" + return saved + + +def _set_azure_env() -> dict[str, str]: + """Set Azure env vars for benchmarking.""" + saved: dict[str, str] = {} + for var in _CLOUD_ENV_VARS: + val = os.environ.pop(var, None) + if val is not None: + saved[var] = val + os.environ["AZURE_SUBSCRIPTION_ID"] = "sub-12345" + os.environ["AZURE_TENANT_ID"] = "tenant-67890" + os.environ["AZURE_CLIENT_ID"] = "client-abcde" + os.environ["AZURE_CLIENT_SECRET"] = "secret-fghij" + return saved + + +def _restore_env(saved: dict[str, str]) -> None: + """Restore env vars from saved dict.""" + for var in _CLOUD_ENV_VARS: + if var in saved: + os.environ[var] = saved[var] + else: + os.environ.pop(var, None) + + +class CloudCredentialResolutionSuite: + """Benchmark credential resolution for all cloud providers.""" + + def setup(self) -> None: + self.saved: dict[str, str] = {} + + def teardown(self) -> None: + _restore_env(self.saved) + + def time_resolve_aws_credentials(self) -> None: + """Benchmark AWS credential resolution.""" + self.saved = _set_aws_env() + resolve_credentials("aws", {}) + + def time_resolve_gcp_credentials(self) -> None: + """Benchmark GCP credential resolution.""" + self.saved = _set_gcp_env() + resolve_credentials("gcp", {}) + + def time_resolve_azure_credentials(self) -> None: + """Benchmark Azure credential resolution.""" + self.saved = _set_azure_env() + resolve_credentials("azure", {}) + + +class CloudCredentialValidationSuite: + """Benchmark credential validation for all cloud providers.""" + + def setup(self) -> None: + self.saved: dict[str, str] = {} + + def teardown(self) -> None: + _restore_env(self.saved) + + def time_validate_aws_credentials(self) -> None: + """Benchmark AWS credential validation.""" + self.saved = _set_aws_env() + resolved = resolve_credentials("aws", {}) + validate_credentials("aws", resolved) + + def time_validate_gcp_credentials(self) -> None: + """Benchmark GCP credential validation.""" + self.saved = _set_gcp_env() + resolved = resolve_credentials("gcp", {}) + validate_credentials("gcp", resolved) + + def time_validate_azure_credentials(self) -> None: + """Benchmark Azure credential validation.""" + self.saved = _set_azure_env() + resolved = resolve_credentials("azure", {}) + validate_credentials("azure", resolved) + + +class CloudHandlerInstantiationSuite: + """Benchmark CloudResourceHandler and CloudSandboxStrategy creation.""" + + def time_create_handler(self) -> None: + """Benchmark handler instantiation.""" + CloudResourceHandler() + + def time_create_sandbox_strategy_aws(self) -> None: + """Benchmark sandbox strategy creation for AWS.""" + CloudSandboxStrategy("aws") + + def time_create_sandbox_strategy_gcp(self) -> None: + """Benchmark sandbox strategy creation for GCP.""" + CloudSandboxStrategy("gcp") + + def time_create_sandbox_strategy_azure(self) -> None: + """Benchmark sandbox strategy creation for Azure.""" + CloudSandboxStrategy("azure") + + +class CloudSandboxValidationSuite: + """Benchmark sandbox strategy validation.""" + + def setup(self) -> None: + self.aws_strategy = CloudSandboxStrategy("aws") + self.gcp_strategy = CloudSandboxStrategy("gcp") + self.azure_strategy = CloudSandboxStrategy("azure") + self.saved: dict[str, str] = {} + + def teardown(self) -> None: + _restore_env(self.saved) + + def time_validate_aws_sandbox(self) -> None: + """Benchmark AWS sandbox validation.""" + self.saved = _set_aws_env() + self.aws_strategy.validate({}) + + def time_validate_gcp_sandbox(self) -> None: + """Benchmark GCP sandbox validation.""" + self.saved = _set_gcp_env() + self.gcp_strategy.validate({}) + + def time_validate_azure_sandbox(self) -> None: + """Benchmark Azure sandbox validation.""" + self.saved = _set_azure_env() + self.azure_strategy.validate({}) diff --git a/benchmarks/context_indexing_bench.py b/benchmarks/context_indexing_bench.py index 79856c652..09c9a4152 100644 --- a/benchmarks/context_indexing_bench.py +++ b/benchmarks/context_indexing_bench.py @@ -15,11 +15,10 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) -import cleveragents # noqa: E402, F401 - from sqlalchemy import create_engine # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 +import cleveragents # noqa: E402, F401 from cleveragents.application.services.repo_indexing_service import ( # noqa: E402 RepoIndexingService, detect_language, diff --git a/benchmarks/db_resource_bench.py b/benchmarks/db_resource_bench.py index 42de50fdc..5221b4811 100644 --- a/benchmarks/db_resource_bench.py +++ b/benchmarks/db_resource_bench.py @@ -41,7 +41,6 @@ from cleveragents.resource.handlers.database import ( # noqa: E402 validate_connection, ) - # --------------------------------------------------------------------------- # Type definition benchmarks # --------------------------------------------------------------------------- diff --git a/benchmarks/devcontainer_lifecycle_bench.py b/benchmarks/devcontainer_lifecycle_bench.py index b9bf9ec83..947196aec 100644 --- a/benchmarks/devcontainer_lifecycle_bench.py +++ b/benchmarks/devcontainer_lifecycle_bench.py @@ -15,6 +15,7 @@ from __future__ import annotations import json import sys from pathlib import Path +from typing import ClassVar try: from cleveragents.domain.models.core.container_lifecycle import ( @@ -127,8 +128,8 @@ class TimeActivationLatency: """Benchmark lazy activation with mock runner.""" timeout = 30 - params: list[int] = [1, 10, 50] - param_names: list[str] = ["num_activations"] + params: ClassVar[list[int]] = [1, 10, 50] + param_names: ClassVar[list[str]] = ["num_activations"] def setup(self, num_activations: int) -> None: """Clear registry before each timing iteration (R17 fix). diff --git a/benchmarks/tdd_session_list_di_bench.py b/benchmarks/tdd_session_list_di_bench.py index fb296a1ca..4ef89d0ec 100644 --- a/benchmarks/tdd_session_list_di_bench.py +++ b/benchmarks/tdd_session_list_di_bench.py @@ -10,9 +10,8 @@ from __future__ import annotations from unittest.mock import MagicMock from benchmarks._session_bench_common import mock_session, runner - -from cleveragents.cli.commands import session as session_mod # noqa: E402 -from cleveragents.cli.commands.session import app as session_app # noqa: E402 +from cleveragents.cli.commands import session as session_mod +from cleveragents.cli.commands.session import app as session_app class TDDSessionListDISuite: diff --git a/docs/reference/cloud_resources.md b/docs/reference/cloud_resources.md new file mode 100644 index 000000000..93dd14911 --- /dev/null +++ b/docs/reference/cloud_resources.md @@ -0,0 +1,166 @@ +# Cloud Infrastructure Resources + +Cloud infrastructure resource types allow CleverAgents to manage +references to cloud provider accounts and their child resources. +Types follow a hierarchical model: generic `cloud-*` base types +define provider-agnostic concepts, while provider-specific types +(e.g. `aws-*`) inherit from the base layer. + +> **Note:** Cloud resource execution is **stubbed**. The handler +> validates configuration and resolves credentials but raises +> `NotImplementedError` for actual sandbox provisioning. This is +> intentional -- cloud SDK integration is planned for a future +> milestone. + +## Architecture + +``` +cloud-account (abstract base) +├── aws-account (user-addable, carries credentials) +│ └── aws-region +│ ├── aws-vpc → aws-subnet, aws-security-group, ... +│ ├── aws-ec2-instance, aws-s3-bucket, aws-ebs-volume +│ ├── aws-ecs-cluster → aws-ecs-service +│ ├── aws-eks-cluster → aws-eks-nodegroup +│ ├── aws-sqs-queue, aws-sns-topic +│ └── aws-cloudwatch-log-group, aws-cloudwatch-alarm +├── gcp (flat, hierarchy pending) +└── azure (flat, hierarchy pending) +``` + +## Provider Entry Points + +| Type | Description | Sandbox Strategy | Inherits | +|---------------|------------------------------------|------------------|-----------------| +| `aws-account` | Amazon Web Services account | `none` | `cloud-account` | +| `gcp` | Google Cloud Platform (flat) | `none` | `cloud-account` | +| `azure` | Microsoft Azure (flat) | `none` | `cloud-account` | + +## Generic Cloud Base Types + +These abstract types are not user-addable. Provider-specific types +inherit from them: + +| Base Type | AWS Inheritor(s) | +|---------------------------|---------------------------------------| +| `cloud-account` | `aws-account`, `gcp`, `azure` | +| `cloud-region` | `aws-region` | +| `cloud-network` | `aws-vpc` | +| `cloud-subnet` | `aws-subnet` | +| `cloud-security-group` | `aws-security-group` | +| `cloud-load-balancer` | `aws-alb`, `aws-nlb` | +| `cloud-compute-instance` | `aws-ec2-instance` | +| `cloud-object-store` | `aws-s3-bucket` | +| `cloud-block-storage` | `aws-ebs-volume` | +| `cloud-identity-principal`| `aws-iam-user` | +| `cloud-role` | `aws-iam-role` | +| `cloud-policy` | `aws-iam-policy` | +| `cloud-log-group` | `aws-cloudwatch-log-group` | +| `cloud-alarm` | `aws-cloudwatch-alarm` | +| `cloud-queue` | `aws-sqs-queue` | +| `cloud-topic` | `aws-sns-topic` | +| `cloud-container-repo` | `aws-ecr-repo` | +| `cloud-container-cluster` | `aws-ecs-cluster`, `aws-eks-cluster` | +| `cloud-container-service` | `aws-ecs-service` | + +## Configuration + +### AWS + +| Field | CLI Arg | Env Var | Required | Sensitive | +|---------------------|----------------------|----------------------------|----------|-----------| +| Access Key ID | `--access-key-id` | `AWS_ACCESS_KEY_ID` | Yes* | Yes | +| Secret Access Key | `--secret-access-key`| `AWS_SECRET_ACCESS_KEY` | Yes* | Yes | +| Session Token | `--session-token` | `AWS_SESSION_TOKEN` | No | Yes | +| Region | `--region` | `AWS_REGION` | No | No | +| Profile | `--profile` | `AWS_PROFILE` | No | No | + +\* Required unless a profile name is configured (via `--profile` or `AWS_PROFILE`). + +### GCP + +| Field | CLI Arg | Env Var | Required | Sensitive | +|--------------------------|------------------------------|---------------------------------|----------|-----------| +| Service Account JSON Path| `--service-account-json-path`| `GOOGLE_APPLICATION_CREDENTIALS`| Yes | Yes | +| Project ID | `--project-id` | `GCLOUD_PROJECT` | Yes | No | +| Region | `--region` | `GCP_REGION` | No | No | + +### Azure + +| Field | CLI Arg | Env Var | Required | Sensitive | +|------------------|---------------------|--------------------------|----------|-----------| +| Subscription ID | `--subscription-id` | `AZURE_SUBSCRIPTION_ID` | Yes | Yes | +| Tenant ID | `--tenant-id` | `AZURE_TENANT_ID` | Yes | Yes | +| Client ID | `--client-id` | `AZURE_CLIENT_ID` | Yes | Yes | +| Client Secret | `--client-secret` | `AZURE_CLIENT_SECRET` | Yes | Yes | +| Region | `--region` | `AZURE_REGION` | No | No | + +## Credential Resolution Order + +Credentials are resolved in this priority order: + +1. **Explicit values** -- Passed directly via resource properties +2. **Environment variables** -- Read from the process environment +3. **Profile names** (AWS only) -- The `AWS_PROFILE` environment + variable or `--profile` CLI argument selects a named profile + from `~/.aws/credentials` + +## Credential Masking + +All credential values are automatically masked in log output and +error messages using the existing redaction system +(`cleveragents.shared.redaction`). Sensitive fields are replaced +with `***REDACTED***`. + +## Handler + +The `CloudResourceHandler` validates configuration and resolves +credentials but does **not** execute cloud operations: + +```python +from cleveragents.resource.handlers.cloud import CloudResourceHandler + +handler = CloudResourceHandler() +# handler.resolve(...) raises NotImplementedError after validation +``` + +## Sandbox Strategy + +Cloud resources use `sandbox_strategy = "none"` because cloud +sandbox isolation is not yet implemented. The `CloudSandboxStrategy` +class provides stubbed `create`, `commit`, and `rollback` methods +that all raise `NotImplementedError`: + +```python +from cleveragents.resource.handlers.cloud import CloudSandboxStrategy + +strategy = CloudSandboxStrategy("aws") +errors = strategy.validate(properties) # validates config +strategy.create(resource_id, plan_id) # raises NotImplementedError +``` + +## Example Usage + +```yaml +# Register an AWS account resource +agents resource add aws-account \ + --access-key-id AKIAIOSFODNN7EXAMPLE \ + --secret-access-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ + --region us-east-1 +``` + +Or using environment variables: + +```bash +export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +export AWS_REGION=us-east-1 + +agents resource add aws-account +``` + +## See Also + +- [Resource Handlers](resource_handlers.md) -- Handler architecture +- [Resource Types (Built-in)](resource_types_builtin.md) -- Built-in types +- [Resources](resources.md) -- Resource model diff --git a/docs/specification.md b/docs/specification.md index b2e409026..9d91e3014 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -24242,6 +24242,100 @@ If a developer edits `src/api.ts` in the deploy directory (`/opt/deploy/acme-das Additional resource types (databases, APIs, cloud infrastructure, etc.) can be added as **custom resource types** via `agents resource type add`. +##### Cloud Infrastructure Resource Types + +Cloud infrastructure types follow a hierarchical model with two layers: + +1. **Generic cloud base types** (`cloud-*`) — provider-agnostic abstractions for common cloud concepts (compute, network, storage, IAM, observability, messaging, containers). These are abstract (not user-addable) and serve as inheritance roots. +2. **Provider-specific types** (e.g., `aws-*`) — concrete types that inherit from the generic base layer and model a specific provider's resource hierarchy. + +**Generic Cloud Base Types** (19 types — abstract, not user-addable): + +| Type | Category | Description | +|------|----------|-------------| +| `cloud-account` | Structure | Cloud provider account or subscription | +| `cloud-region` | Structure | Geographic region within an account | +| `cloud-network` | Network | Virtual network (VPC, VNet, etc.) | +| `cloud-subnet` | Network | Subnet within a virtual network | +| `cloud-security-group` | Network | Network security rules / firewall group | +| `cloud-load-balancer` | Network | Network load balancer | +| `cloud-compute-instance` | Compute | Virtual machine or compute instance | +| `cloud-object-store` | Storage | Object / blob storage bucket | +| `cloud-block-storage` | Storage | Block storage volume | +| `cloud-identity-principal` | IAM | IAM user or service principal | +| `cloud-role` | IAM | IAM role | +| `cloud-policy` | IAM | IAM or access policy document | +| `cloud-log-group` | Observability | Log aggregation group | +| `cloud-alarm` | Observability | Monitoring alarm or alert | +| `cloud-queue` | Messaging | Message queue | +| `cloud-topic` | Messaging | Pub/sub notification topic | +| `cloud-container-repo` | Containers | Container image registry / repository | +| `cloud-container-cluster` | Containers | Container orchestration cluster | +| `cloud-container-service` | Containers | Container workload / service | + +**AWS Provider Types** (39 types — inheriting from generic base where applicable): + +| Type | Inherits | User Addable | Parent Types | Category | +|------|----------|-------------|--------------|----------| +| `aws-account` | `cloud-account` | **yes** | (root) | Account | +| `aws-region` | `cloud-region` | no | `aws-account` | Structure | +| `aws-vpc` | `cloud-network` | no | `aws-region` | Network | +| `aws-subnet` | `cloud-subnet` | no | `aws-vpc` | Network | +| `aws-igw` | — | no | `aws-vpc` | Network | +| `aws-nat-gw` | — | no | `aws-subnet`, `aws-vpc` | Network | +| `aws-route-table` | — | no | `aws-vpc` | Network | +| `aws-nacl` | — | no | `aws-vpc` | Network | +| `aws-security-group` | `cloud-security-group` | no | `aws-vpc` | Network | +| `aws-alb` | `cloud-load-balancer` | no | `aws-vpc` | Network | +| `aws-nlb` | `cloud-load-balancer` | no | `aws-vpc` | Network | +| `aws-target-group` | — | no | `aws-vpc` | Network | +| `aws-listener` | — | no | `aws-alb`, `aws-nlb` | Network | +| `aws-ec2-instance` | `cloud-compute-instance` | no | `aws-subnet`, `aws-region` | Compute | +| `aws-ami` | — | no | `aws-region` | Compute | +| `aws-launch-template` | — | no | `aws-region` | Compute | +| `aws-asg` | — | no | `aws-region` | Compute | +| `aws-s3-bucket` | `cloud-object-store` | no | `aws-region` | Storage | +| `aws-ebs-volume` | `cloud-block-storage` | no | `aws-region` | Storage | +| `aws-efs-filesystem` | — | no | `aws-region` | Storage | +| `aws-iam-user` | `cloud-identity-principal` | no | `aws-account` | IAM | +| `aws-iam-role` | `cloud-role` | no | `aws-account` | IAM | +| `aws-iam-policy` | `cloud-policy` | no | `aws-account` | IAM | +| `aws-iam-instance-profile` | — | no | `aws-account` | IAM | +| `aws-cloudwatch-log-group` | `cloud-log-group` | no | `aws-region` | Observability | +| `aws-cloudwatch-alarm` | `cloud-alarm` | no | `aws-region` | Observability | +| `aws-cloudwatch-metric` | — | no | `aws-region` | Observability | +| `aws-eventbridge-bus` | — | no | `aws-region` | Observability | +| `aws-eventbridge-rule` | — | no | `aws-eventbridge-bus` | Observability | +| `aws-eventbridge-target` | — | no | `aws-eventbridge-rule` | Observability | +| `aws-sqs-queue` | `cloud-queue` | no | `aws-region` | Messaging | +| `aws-sns-topic` | `cloud-topic` | no | `aws-region` | Messaging | +| `aws-sns-subscription` | — | no | `aws-sns-topic` | Messaging | +| `aws-ecr-repo` | `cloud-container-repo` | no | `aws-region` | Containers | +| `aws-ecs-cluster` | `cloud-container-cluster` | no | `aws-region` | Containers | +| `aws-ecs-service` | `cloud-container-service` | no | `aws-ecs-cluster` | Containers | +| `aws-ecs-task-def` | — | no | `aws-region` | Containers | +| `aws-eks-cluster` | `cloud-container-cluster` | no | `aws-region` | Containers | +| `aws-eks-nodegroup` | — | no | `aws-eks-cluster` | Containers | + +**GCP and Azure** are registered as flat provider-level types inheriting from `cloud-account`. Full hierarchies for these providers are deferred to future PRs. + +Only `aws-account` is user-addable — it is the top-level entry point carrying credential CLI args (`--access-key-id`, `--secret-access-key`, `--session-token`, `--region`, `--profile`). All other AWS types are children discovered or created within the account/region/VPC containment hierarchy. + +Cloud resource execution is **stubbed** — the handler validates configuration and resolves credentials but raises `NotImplementedError` for actual sandbox provisioning. Cloud SDK integration is planned for a future milestone. + +##### Database Resource Types + +Built-in database types use the `transaction_rollback` sandbox strategy: + +| Type | User Addable | Sandbox | Description | +|------|-------------|---------|-------------| +| `postgres` | yes | `transaction_rollback` | PostgreSQL database connection | +| `mysql` | yes | `transaction_rollback` | MySQL database connection | +| `sqlite` | yes | `transaction_rollback` | SQLite database file | +| `duckdb` | yes | `transaction_rollback` | DuckDB database (file-based or in-memory) | + +Networked databases (`postgres`, `mysql`) accept `--connection-string`, `--host`, `--port`, `--dbname`, `--user`, `--password` CLI args. File-based databases (`sqlite`, `duckdb`) accept `--path`. Database hierarchy restructuring (inheritance from a generic `database` base type) is deferred to a separate effort. + ##### Custom Resource Types Custom resource types are defined in YAML configuration files and registered via `agents resource type add`. Once registered, a custom type automatically becomes available as a new subcommand under `agents resource add`. diff --git a/features/cloud_resources.feature b/features/cloud_resources.feature new file mode 100644 index 000000000..3d313517a --- /dev/null +++ b/features/cloud_resources.feature @@ -0,0 +1,245 @@ +Feature: Cloud Infrastructure Resources + As a CleverAgents user + I want to define cloud infrastructure resources (aws, gcp, azure) + So that I can register and validate cloud provider configurations + + # ── AWS resource type ───────────────────────────────────── + + Scenario: AWS account resource type definition is valid + Given a cloud resource type definition for "aws-account" + Then the cloud type name should be "aws-account" + And the cloud type sandbox_strategy should be "none" + And the cloud type resource_kind should be "physical" + And the cloud type should be built_in + + Scenario: AWS account resource type has correct CLI args + Given a cloud resource type definition for "aws-account" + Then the cloud type should have 5 cli_args + And the cloud type cli_args should include "access-key-id" + And the cloud type cli_args should include "secret-access-key" + And the cloud type cli_args should include "session-token" + And the cloud type cli_args should include "region" + And the cloud type cli_args should include "profile" + + Scenario: AWS account type inherits from cloud-account + Given a cloud resource type definition for "aws-account" + Then the cloud type should inherit from "cloud-account" + + Scenario: AWS credential resolution from env vars + Given AWS environment variables are set + When I resolve credentials for provider "aws" + Then the resolved credential "access-key-id" should not be None + And the resolved credential "secret-access-key" should not be None + And the resolved credential "region" should not be None + + Scenario: AWS credential validation passes with env vars + Given AWS environment variables are set + When I resolve credentials for provider "aws" + And I validate credentials for provider "aws" + Then there should be no cloud validation errors + + Scenario: AWS credential validation fails without credentials + Given no cloud environment variables are set + When I resolve credentials for provider "aws" + And I validate credentials for provider "aws" + Then there should be cloud validation errors + And the cloud validation errors should mention "access-key-id" + And the cloud validation errors should mention "secret-access-key" + + Scenario: AWS profile name satisfies credential requirements + Given AWS profile environment variable is set to "my-profile" + When I resolve credentials for provider "aws" + And I validate credentials for provider "aws" + Then there should be no cloud validation errors + + # ── GCP resource type ───────────────────────────────────── + + Scenario: GCP resource type definition is valid + Given a cloud resource type definition for "gcp" + Then the cloud type name should be "gcp" + And the cloud type sandbox_strategy should be "none" + And the cloud type resource_kind should be "physical" + And the cloud type should be built_in + + Scenario: GCP resource type has correct CLI args + Given a cloud resource type definition for "gcp" + Then the cloud type should have 3 cli_args + And the cloud type cli_args should include "service-account-json-path" + And the cloud type cli_args should include "project-id" + And the cloud type cli_args should include "region" + + Scenario: GCP credential resolution from env vars + Given GCP environment variables are set + When I resolve credentials for provider "gcp" + Then the resolved credential "service-account-json-path" should not be None + And the resolved credential "project-id" should not be None + + Scenario: GCP credential validation passes with env vars + Given GCP environment variables are set + When I resolve credentials for provider "gcp" + And I validate credentials for provider "gcp" + Then there should be no cloud validation errors + + Scenario: GCP credential validation fails without credentials + Given no cloud environment variables are set + When I resolve credentials for provider "gcp" + And I validate credentials for provider "gcp" + Then there should be cloud validation errors + And the cloud validation errors should mention "service-account-json-path" + And the cloud validation errors should mention "project-id" + + # ── Azure resource type ─────────────────────────────────── + + Scenario: Azure resource type definition is valid + Given a cloud resource type definition for "azure" + Then the cloud type name should be "azure" + And the cloud type sandbox_strategy should be "none" + And the cloud type resource_kind should be "physical" + And the cloud type should be built_in + + Scenario: Azure resource type has correct CLI args + Given a cloud resource type definition for "azure" + Then the cloud type should have 5 cli_args + And the cloud type cli_args should include "subscription-id" + And the cloud type cli_args should include "tenant-id" + And the cloud type cli_args should include "client-id" + And the cloud type cli_args should include "client-secret" + And the cloud type cli_args should include "region" + + Scenario: Azure credential resolution from env vars + Given Azure environment variables are set + When I resolve credentials for provider "azure" + Then the resolved credential "subscription-id" should not be None + And the resolved credential "tenant-id" should not be None + And the resolved credential "client-id" should not be None + And the resolved credential "client-secret" should not be None + + Scenario: Azure credential validation passes with env vars + Given Azure environment variables are set + When I resolve credentials for provider "azure" + And I validate credentials for provider "azure" + Then there should be no cloud validation errors + + Scenario: Azure credential validation fails without credentials + Given no cloud environment variables are set + When I resolve credentials for provider "azure" + And I validate credentials for provider "azure" + Then there should be cloud validation errors + And the cloud validation errors should mention "subscription-id" + And the cloud validation errors should mention "tenant-id" + And the cloud validation errors should mention "client-id" + And the cloud validation errors should mention "client-secret" + + # ── Cloud hierarchy types ──────────────────────────────── + + Scenario: Generic cloud-account base type exists + Given a cloud resource type definition for "cloud-account" + Then the cloud type name should be "cloud-account" + And the cloud type should be built_in + And the cloud type should not be user_addable + + Scenario: AWS VPC inherits from cloud-network + Given a cloud resource type definition for "aws-vpc" + Then the cloud type should inherit from "cloud-network" + And the cloud type should not be user_addable + + Scenario: AWS S3 bucket inherits from cloud-object-store + Given a cloud resource type definition for "aws-s3-bucket" + Then the cloud type should inherit from "cloud-object-store" + And the cloud type should not be user_addable + + Scenario: AWS ECS cluster inherits from cloud-container-cluster + Given a cloud resource type definition for "aws-ecs-cluster" + Then the cloud type should inherit from "cloud-container-cluster" + + Scenario: GCP type inherits from cloud-account + Given a cloud resource type definition for "gcp" + Then the cloud type should inherit from "cloud-account" + + Scenario: Azure type inherits from cloud-account + Given a cloud resource type definition for "azure" + Then the cloud type should inherit from "cloud-account" + + # ── Credential masking ──────────────────────────────────── + + Scenario: Credential values are masked in error messages + Given no cloud environment variables are set + When I resolve credentials for provider "aws" + And I validate credentials for provider "aws" + Then the cloud validation errors should not contain secrets + + # ── Stub execution ──────────────────────────────────────── + + Scenario: Cloud handler resolve raises NotImplementedError for AWS + Given a valid AWS cloud resource + When I call resolve on the cloud handler + Then a cloud NotImplementedError should be raised + And the cloud error message should mention "aws" + + Scenario: Cloud handler resolve raises NotImplementedError for GCP + Given a valid GCP cloud resource + When I call resolve on the cloud handler + Then a cloud NotImplementedError should be raised + And the cloud error message should mention "gcp" + + Scenario: Cloud handler resolve raises NotImplementedError for Azure + Given a valid Azure cloud resource + When I call resolve on the cloud handler + Then a cloud NotImplementedError should be raised + And the cloud error message should mention "azure" + + # ── Missing credential error ────────────────────────────── + + Scenario: Cloud handler raises ValueError for missing AWS credentials + Given an AWS cloud resource without credentials + When I call resolve on the cloud handler + Then a cloud ValueError should be raised + And the cloud error message should mention "access-key-id" + + Scenario: Cloud handler raises ValueError for missing GCP credentials + Given a GCP cloud resource without credentials + When I call resolve on the cloud handler + Then a cloud ValueError should be raised + And the cloud error message should mention "service-account-json-path" + + Scenario: Cloud handler raises ValueError for missing Azure credentials + Given an Azure cloud resource without credentials + When I call resolve on the cloud handler + Then a cloud ValueError should be raised + And the cloud error message should mention "subscription-id" + + # ── Cloud sandbox strategy stubs ────────────────────────── + + Scenario: Cloud sandbox create raises NotImplementedError + Given a cloud sandbox strategy for "aws" + When I call create on the sandbox strategy + Then a cloud NotImplementedError should be raised + + Scenario: Cloud sandbox commit raises NotImplementedError + Given a cloud sandbox strategy for "gcp" + When I call commit on the sandbox strategy + Then a cloud NotImplementedError should be raised + + Scenario: Cloud sandbox rollback raises NotImplementedError + Given a cloud sandbox strategy for "azure" + When I call rollback on the sandbox strategy + Then a cloud NotImplementedError should be raised + + Scenario: Cloud sandbox validate returns no errors with valid config + Given a cloud sandbox strategy for "aws" + And AWS environment variables are set + When I validate the cloud sandbox strategy + Then there should be no cloud validation errors + + # ── Unknown provider ────────────────────────────────────── + + Scenario: Unknown provider raises ValueError + When I try to resolve credentials for provider "alibaba" + Then a cloud ValueError should be raised + And the cloud error message should mention "alibaba" + + # ── Handler protocol conformance ────────────────────────── + + Scenario: CloudResourceHandler satisfies ResourceHandler protocol + Given a CloudResourceHandler instance + Then the cloud handler should satisfy the ResourceHandler protocol diff --git a/features/steps/cloud_resources_steps.py b/features/steps/cloud_resources_steps.py new file mode 100644 index 000000000..56b234538 --- /dev/null +++ b/features/steps/cloud_resources_steps.py @@ -0,0 +1,434 @@ +"""Step definitions for cloud_resources.feature.""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when # type: ignore[attr-defined] + +from cleveragents.application.services._resource_registry_data import ( + BUILTIN_TYPES as _BUILTIN_TYPES, +) +from cleveragents.domain.models.core.resource import ( + PhysVirt, + Resource, + ResourceCapabilities, +) +from cleveragents.resource.handlers.cloud import ( + CloudResourceHandler, + CloudSandboxStrategy, + resolve_credentials, + validate_credentials, +) +from cleveragents.resource.handlers.protocol import ResourceHandler + +# ── Environment variable management ───────────────────────── + +_CLOUD_ENV_VARS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", + "GCLOUD_PROJECT", + "GCP_REGION", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_REGION", +] + + +def _clear_cloud_env() -> dict[str, str]: + """Remove all cloud env vars and return saved values.""" + saved: dict[str, str] = {} + for var in _CLOUD_ENV_VARS: + val = os.environ.pop(var, None) + if val is not None: + saved[var] = val + return saved + + +def _restore_cloud_env(saved: dict[str, str]) -> None: + """Restore previously saved env vars.""" + for var in _CLOUD_ENV_VARS: + if var in saved: + os.environ[var] = saved[var] + else: + os.environ.pop(var, None) + + +def _make_resource( # type: ignore[attr-defined] + provider: str, + properties: dict[str, Any] | None = None, +) -> Resource: + """Create a test cloud resource.""" + return Resource( + resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS", + resource_type_name=provider, + classification=PhysVirt.PHYSICAL, + location=None, + properties=properties or {}, + capabilities=ResourceCapabilities( + readable=True, + writable=True, + sandboxable=False, + checkpointable=False, + ), + ) + + +# ── Given steps ────────────────────────────────────────────── + + +@given('a cloud resource type definition for "{provider}"') +def step_cloud_type_def(context: Any, provider: str) -> None: + """Load the built-in cloud type definition.""" + for builtin in _BUILTIN_TYPES: + if builtin["name"] == provider: + context.cloud_type_def = builtin # type: ignore[attr-defined] + return + raise AssertionError(f"Built-in type '{provider}' not found in _BUILTIN_TYPES") + + +@given("AWS environment variables are set") +def step_aws_env_set(context: Any) -> None: + """Set AWS environment variables for testing.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + os.environ["AWS_REGION"] = "us-east-1" + + +@given("GCP environment variables are set") +def step_gcp_env_set(context: Any) -> None: + """Set GCP environment variables for testing.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/path/to/sa-key.json" + os.environ["GCLOUD_PROJECT"] = "my-gcp-project" + + +@given("Azure environment variables are set") +def step_azure_env_set(context: Any) -> None: + """Set Azure environment variables for testing.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["AZURE_SUBSCRIPTION_ID"] = "sub-12345" + os.environ["AZURE_TENANT_ID"] = "tenant-67890" + os.environ["AZURE_CLIENT_ID"] = "client-abcde" + os.environ["AZURE_CLIENT_SECRET"] = "secret-fghij" + + +@given("no cloud environment variables are set") +def step_no_cloud_env(context: Any) -> None: + """Clear all cloud environment variables.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + + +@given('AWS profile environment variable is set to "{profile}"') +def step_aws_profile_set(context: Any, profile: str) -> None: + """Set only the AWS_PROFILE env var.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["AWS_PROFILE"] = profile + + +@given("a valid AWS cloud resource") +def step_valid_aws_resource(context: Any) -> None: + """Create an AWS resource with credentials from env vars.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + context.cloud_resource = _make_resource("aws") # type: ignore[attr-defined] + + +@given("a valid GCP cloud resource") +def step_valid_gcp_resource(context: Any) -> None: + """Create a GCP resource with credentials from env vars.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/path/to/sa-key.json" + os.environ["GCLOUD_PROJECT"] = "my-gcp-project" + context.cloud_resource = _make_resource("gcp") # type: ignore[attr-defined] + + +@given("a valid Azure cloud resource") +def step_valid_azure_resource(context: Any) -> None: + """Create an Azure resource with credentials from env vars.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + os.environ["AZURE_SUBSCRIPTION_ID"] = "sub-12345" + os.environ["AZURE_TENANT_ID"] = "tenant-67890" + os.environ["AZURE_CLIENT_ID"] = "client-abcde" + os.environ["AZURE_CLIENT_SECRET"] = "secret-fghij" + context.cloud_resource = _make_resource("azure") # type: ignore[attr-defined] + + +@given("an AWS cloud resource without credentials") +def step_aws_no_creds(context: Any) -> None: + """Create an AWS resource with no credentials.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + context.cloud_resource = _make_resource("aws") # type: ignore[attr-defined] + + +@given("a GCP cloud resource without credentials") +def step_gcp_no_creds(context: Any) -> None: + """Create a GCP resource with no credentials.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + context.cloud_resource = _make_resource("gcp") # type: ignore[attr-defined] + + +@given("an Azure cloud resource without credentials") +def step_azure_no_creds(context: Any) -> None: + """Create an Azure resource with no credentials.""" + context.saved_env = _clear_cloud_env() # type: ignore[attr-defined] + context.cloud_resource = _make_resource("azure") # type: ignore[attr-defined] + + +@given('a cloud sandbox strategy for "{provider}"') +def step_cloud_sandbox_strategy(context: Any, provider: str) -> None: + """Create a cloud sandbox strategy instance.""" + context.cloud_sandbox = CloudSandboxStrategy(provider) # type: ignore[attr-defined] + + +@given("a CloudResourceHandler instance") +def step_cloud_handler_instance(context: Any) -> None: + """Create a CloudResourceHandler instance.""" + context.cloud_handler = CloudResourceHandler() # type: ignore[attr-defined] + + +# ── When steps ─────────────────────────────────────────────── + + +@when('I resolve credentials for provider "{provider}"') +def step_resolve_creds(context: Any, provider: str) -> None: + """Resolve credentials for the given provider.""" + try: + context.resolved_creds = resolve_credentials(provider, {}) # type: ignore[attr-defined] + context.resolve_error = None # type: ignore[attr-defined] + except (ValueError, KeyError) as exc: + context.resolved_creds = {} # type: ignore[attr-defined] + context.resolve_error = exc # type: ignore[attr-defined] + finally: + saved = getattr(context, "saved_env", {}) + _restore_cloud_env(saved) + + +@when('I validate credentials for provider "{provider}"') +def step_validate_creds(context: Any, provider: str) -> None: + """Validate resolved credentials.""" + resolved: dict[str, str | None] = getattr(context, "resolved_creds", {}) + context.validation_errors = validate_credentials(provider, resolved) # type: ignore[attr-defined] + + +@when("I call resolve on the cloud handler") +def step_call_resolve(context: Any) -> None: + """Call resolve on CloudResourceHandler.""" + handler = CloudResourceHandler() + resource: Resource = context.cloud_resource # type: ignore[attr-defined] + mock_manager = MagicMock() + try: + handler.resolve( + resource=resource, + plan_id="PLAN_TEST", + slot_name="cloud-slot", + sandbox_manager=mock_manager, + ) + context.handler_error = None # type: ignore[attr-defined] + context.handler_error_type = None # type: ignore[attr-defined] + except NotImplementedError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "NotImplementedError" # type: ignore[attr-defined] + except ValueError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "ValueError" # type: ignore[attr-defined] + finally: + saved = getattr(context, "saved_env", {}) + _restore_cloud_env(saved) + + +@when('I try to resolve credentials for provider "{provider}"') +def step_try_resolve_unknown(context: Any, provider: str) -> None: + """Try to resolve credentials for an unknown provider.""" + try: + resolve_credentials(provider, {}) + context.handler_error = None # type: ignore[attr-defined] + context.handler_error_type = None # type: ignore[attr-defined] + except ValueError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "ValueError" # type: ignore[attr-defined] + + +@when("I call create on the sandbox strategy") +def step_sandbox_create(context: Any) -> None: + """Call create on the cloud sandbox strategy.""" + strategy: CloudSandboxStrategy = context.cloud_sandbox # type: ignore[attr-defined] + try: + strategy.create("res-test", "plan-test") + context.handler_error = None # type: ignore[attr-defined] + context.handler_error_type = None # type: ignore[attr-defined] + except NotImplementedError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "NotImplementedError" # type: ignore[attr-defined] + + +@when("I call commit on the sandbox strategy") +def step_sandbox_commit(context: Any) -> None: + """Call commit on the cloud sandbox strategy.""" + strategy: CloudSandboxStrategy = context.cloud_sandbox # type: ignore[attr-defined] + try: + strategy.commit("res-test", "plan-test") + context.handler_error = None # type: ignore[attr-defined] + context.handler_error_type = None # type: ignore[attr-defined] + except NotImplementedError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "NotImplementedError" # type: ignore[attr-defined] + + +@when("I call rollback on the sandbox strategy") +def step_sandbox_rollback(context: Any) -> None: + """Call rollback on the cloud sandbox strategy.""" + strategy: CloudSandboxStrategy = context.cloud_sandbox # type: ignore[attr-defined] + try: + strategy.rollback("res-test", "plan-test") + context.handler_error = None # type: ignore[attr-defined] + context.handler_error_type = None # type: ignore[attr-defined] + except NotImplementedError as exc: + context.handler_error = exc # type: ignore[attr-defined] + context.handler_error_type = "NotImplementedError" # type: ignore[attr-defined] + + +@when("I validate the cloud sandbox strategy") +def step_validate_sandbox(context: Any) -> None: + """Validate the cloud sandbox strategy.""" + strategy: CloudSandboxStrategy = context.cloud_sandbox # type: ignore[attr-defined] + context.validation_errors = strategy.validate({}) # type: ignore[attr-defined] + saved = getattr(context, "saved_env", {}) + _restore_cloud_env(saved) + + +# ── Then steps ─────────────────────────────────────────────── + + +@then('the cloud type name should be "{name}"') +def step_cloud_type_name(context: Any, name: str) -> None: + """Check cloud type name.""" + assert context.cloud_type_def["name"] == name # type: ignore[attr-defined] + + +@then('the cloud type sandbox_strategy should be "{strategy}"') +def step_cloud_type_strategy(context: Any, strategy: str) -> None: + """Check cloud type sandbox strategy.""" + assert context.cloud_type_def["sandbox_strategy"] == strategy # type: ignore[attr-defined] + + +@then('the cloud type resource_kind should be "{kind}"') +def step_cloud_type_kind(context: Any, kind: str) -> None: + """Check cloud type resource kind.""" + assert context.cloud_type_def["resource_kind"] == kind # type: ignore[attr-defined] + + +@then("the cloud type should be built_in") +def step_cloud_type_builtin(context: Any) -> None: + """Check cloud type is built-in.""" + assert context.cloud_type_def["built_in"] is True # type: ignore[attr-defined] + + +@then("the cloud type should not be user_addable") +def step_cloud_type_not_user_addable(context: Any) -> None: + """Check cloud type is not user-addable.""" + assert context.cloud_type_def["user_addable"] is False # type: ignore[attr-defined] + + +@then("the cloud type should have {count:d} cli_args") +def step_cloud_type_cli_args_count(context: Any, count: int) -> None: + """Check cloud type CLI args count.""" + assert len(context.cloud_type_def["cli_args"]) == count # type: ignore[attr-defined] + + +@then('the cloud type cli_args should include "{arg_name}"') +def step_cloud_type_cli_arg(context: Any, arg_name: str) -> None: + """Check cloud type has a specific CLI arg.""" + names = [a["name"] for a in context.cloud_type_def["cli_args"]] # type: ignore[attr-defined] + assert arg_name in names, f"'{arg_name}' not in {names}" + + +@then('the resolved credential "{field}" should not be None') +def step_cred_not_none(context: Any, field: str) -> None: + """Check a resolved credential is not None.""" + assert context.resolved_creds.get(field) is not None, ( # type: ignore[attr-defined] + f"Credential '{field}' is None" + ) + + +@then("there should be no cloud validation errors") +def step_no_cloud_validation_errors(context: Any) -> None: + """Check there are no cloud validation errors.""" + errors: list[str] = getattr(context, "validation_errors", []) + assert len(errors) == 0, f"Expected no errors, got: {errors}" + + +@then("there should be cloud validation errors") +def step_has_cloud_validation_errors(context: Any) -> None: + """Check there are cloud validation errors.""" + errors: list[str] = getattr(context, "validation_errors", []) + assert len(errors) > 0, "Expected validation errors but got none" + + +@then('the cloud validation errors should mention "{field}"') +def step_cloud_errors_mention(context: Any, field: str) -> None: + """Check cloud validation errors mention a specific field.""" + errors: list[str] = getattr(context, "validation_errors", []) + combined = " ".join(errors) + assert field in combined, f"'{field}' not found in errors: {combined}" + + +@then("the cloud validation errors should not contain secrets") +def step_no_secrets_in_cloud_errors(context: Any) -> None: + """Ensure no raw secret values appear in error messages.""" + errors: list[str] = getattr(context, "validation_errors", []) + combined = " ".join(errors) + # Secrets should never appear in error text + assert "AKIAIOSFODNN7EXAMPLE" not in combined + assert "wJalrXUtnFEMI" not in combined + + +@then("a cloud NotImplementedError should be raised") +def step_cloud_not_implemented(context: Any) -> None: + """Check that a NotImplementedError was raised.""" + assert context.handler_error_type == "NotImplementedError", ( # type: ignore[attr-defined] + f"Expected NotImplementedError, got {context.handler_error_type}: " # type: ignore[attr-defined] + f"{context.handler_error}" # type: ignore[attr-defined] + ) + + +@then("a cloud ValueError should be raised") +def step_cloud_value_error(context: Any) -> None: + """Check that a ValueError was raised.""" + assert context.handler_error_type == "ValueError", ( # type: ignore[attr-defined] + f"Expected ValueError, got {context.handler_error_type}: " # type: ignore[attr-defined] + f"{context.handler_error}" # type: ignore[attr-defined] + ) + + +@then('the cloud error message should mention "{text}"') +def step_cloud_error_mentions(context: Any, text: str) -> None: + """Check cloud error message contains expected text.""" + msg = str(context.handler_error) # type: ignore[attr-defined] + assert text in msg, f"'{text}' not found in error: {msg}" + + +@then('the cloud type should inherit from "{parent}"') +def step_cloud_type_inherits(context: Any, parent: str) -> None: + """Check cloud type inherits from expected parent.""" + actual = context.cloud_type_def.get("inherits") # type: ignore[attr-defined] + assert actual == parent, f"Expected inherits='{parent}', got '{actual}'" + + +@then("the cloud handler should satisfy the ResourceHandler protocol") +def step_cloud_satisfies_protocol(context: Any) -> None: + """Check CloudResourceHandler satisfies ResourceHandler protocol.""" + handler: CloudResourceHandler = context.cloud_handler # type: ignore[attr-defined] + assert isinstance(handler, ResourceHandler), ( + "CloudResourceHandler does not satisfy ResourceHandler protocol" + ) diff --git a/hooks/adr_hooks.py b/hooks/adr_hooks.py index efaa40b1a..cdfa5674c 100644 --- a/hooks/adr_hooks.py +++ b/hooks/adr_hooks.py @@ -132,7 +132,7 @@ def _build_status_badge(status: str, extra_class: str = "") -> str: def _resolve_adr_link(adr_number: int, files: Any) -> str | None: - """Find the relative URL for an ADR by its number, using the MkDocs files collection.""" + """Find the relative URL for an ADR by its number.""" prefix = f"adr/ADR-{adr_number:03d}" for f in files: if f.src_path.startswith(prefix) and f.src_path.endswith(".md"): @@ -189,7 +189,10 @@ def _build_header( f'ADR-{superseded_by:03d}' ) else: - superseded_part = f' — superseded by ADR-{superseded_by:03d}' + superseded_part = ( + f' ' + f"— superseded by ADR-{superseded_by:03d}" + ) line2 = ( f'