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'
' @@ -664,12 +667,8 @@ def on_page_markdown( generated_h1 = f"# ADR-{int(adr_number):03d}: {adr_title}\n" else: # Fallback: keep whatever H1 already exists - generated_h1 = "" h1_match = re.match(r"(#\s+.+?\n)", markdown) - if h1_match: - generated_h1 = "" # leave it in the markdown - else: - generated_h1 = "" + generated_h1 = "" # leave existing H1 or use empty # ── build header + timeline ───────────────────────────────────── header_html = _build_header( diff --git a/robot/cloud_resources.robot b/robot/cloud_resources.robot new file mode 100644 index 000000000..cd2285bea --- /dev/null +++ b/robot/cloud_resources.robot @@ -0,0 +1,81 @@ +*** Settings *** +Documentation Smoke tests for cloud infrastructure resource handlers +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_cloud_resources.py + +*** Test Cases *** +Cloud Handler Protocol Conformance + [Documentation] Verify CloudResourceHandler satisfies ResourceHandler + ${result}= Run Process ${PYTHON} ${HELPER} protocol-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} protocol-check-ok + +AWS Credential Resolution From Env Vars + [Documentation] Verify AWS credential resolution from environment variables + ${result}= Run Process ${PYTHON} ${HELPER} aws-resolve cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} aws-resolve-ok + +GCP Credential Resolution From Env Vars + [Documentation] Verify GCP credential resolution from environment variables + ${result}= Run Process ${PYTHON} ${HELPER} gcp-resolve cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} gcp-resolve-ok + +Azure Credential Resolution From Env Vars + [Documentation] Verify Azure credential resolution from environment variables + ${result}= Run Process ${PYTHON} ${HELPER} azure-resolve cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} azure-resolve-ok + +Cloud Handler Stub Raises NotImplementedError + [Documentation] Verify cloud handler resolve raises NotImplementedError for valid config + ${result}= Run Process ${PYTHON} ${HELPER} stub-resolve cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} stub-resolve-ok + +Cloud Handler Missing Creds Raises ValueError + [Documentation] Verify cloud handler raises ValueError for missing credentials + ${result}= Run Process ${PYTHON} ${HELPER} missing-creds cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} missing-creds-ok + +Cloud Sandbox Strategy Stubs + [Documentation] Verify cloud sandbox strategy methods raise NotImplementedError + ${result}= Run Process ${PYTHON} ${HELPER} sandbox-stubs cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} sandbox-stubs-ok + +AWS Profile Resolution + [Documentation] Verify AWS profile name resolves as credential source + ${result}= Run Process ${PYTHON} ${HELPER} aws-profile cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} aws-profile-ok + +Cloud Builtin Types Registered + [Documentation] Verify cloud hierarchy types are in _BUILTIN_TYPES + ${result}= Run Process ${PYTHON} ${HELPER} builtin-types cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} builtin-types-ok diff --git a/robot/helper_cloud_resources.py b/robot/helper_cloud_resources.py new file mode 100644 index 000000000..0c1c71305 --- /dev/null +++ b/robot/helper_cloud_resources.py @@ -0,0 +1,259 @@ +"""Helper utilities for cloud resource Robot smoke tests. + +Each command prints a single ``-ok`` token on success so the +calling Robot test can assert on ``stdout``. +""" + +from __future__ import annotations + +import os +import sys +from typing import Any +from unittest.mock import MagicMock + +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 + +_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( + 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, + ), + ) + + +def _protocol_check() -> None: + """Verify CloudResourceHandler satisfies the ResourceHandler protocol.""" + handler = CloudResourceHandler() + assert isinstance(handler, ResourceHandler), ( + "CloudResourceHandler not a ResourceHandler" + ) + print("protocol-check-ok") + + +def _aws_resolve() -> None: + """Verify AWS credential resolution from env vars.""" + saved = _clear_cloud_env() + try: + os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/EXAMPLEKEY" + os.environ["AWS_REGION"] = "us-east-1" + resolved = resolve_credentials("aws", {}) + assert resolved["access-key-id"] is not None + assert resolved["secret-access-key"] is not None + assert resolved["region"] == "us-east-1" + errors = validate_credentials("aws", resolved) + assert len(errors) == 0, f"Unexpected errors: {errors}" + print("aws-resolve-ok") + finally: + _restore_cloud_env(saved) + + +def _gcp_resolve() -> None: + """Verify GCP credential resolution from env vars.""" + saved = _clear_cloud_env() + try: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/path/to/sa-key.json" + os.environ["GCLOUD_PROJECT"] = "my-gcp-project" + resolved = resolve_credentials("gcp", {}) + assert resolved["service-account-json-path"] is not None + assert resolved["project-id"] == "my-gcp-project" + errors = validate_credentials("gcp", resolved) + assert len(errors) == 0, f"Unexpected errors: {errors}" + print("gcp-resolve-ok") + finally: + _restore_cloud_env(saved) + + +def _azure_resolve() -> None: + """Verify Azure credential resolution from env vars.""" + saved = _clear_cloud_env() + try: + 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" + resolved = resolve_credentials("azure", {}) + assert resolved["subscription-id"] is not None + assert resolved["tenant-id"] is not None + assert resolved["client-id"] is not None + assert resolved["client-secret"] is not None + errors = validate_credentials("azure", resolved) + assert len(errors) == 0, f"Unexpected errors: {errors}" + print("azure-resolve-ok") + finally: + _restore_cloud_env(saved) + + +def _stub_resolve() -> None: + """Verify cloud handler raises NotImplementedError for valid config.""" + saved = _clear_cloud_env() + try: + os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE" + os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/EXAMPLEKEY" + handler = CloudResourceHandler() + resource = _make_resource("aws") + mock_manager = MagicMock() + try: + handler.resolve( + resource=resource, + plan_id="PLAN_ROBOT", + slot_name="cloud-slot", + sandbox_manager=mock_manager, + ) + print("stub-resolve-FAIL: no error raised") + except NotImplementedError: + print("stub-resolve-ok") + finally: + _restore_cloud_env(saved) + + +def _missing_creds() -> None: + """Verify cloud handler raises ValueError for missing credentials.""" + saved = _clear_cloud_env() + try: + handler = CloudResourceHandler() + resource = _make_resource("aws") + mock_manager = MagicMock() + try: + handler.resolve( + resource=resource, + plan_id="PLAN_ROBOT", + slot_name="cloud-slot", + sandbox_manager=mock_manager, + ) + print("missing-creds-FAIL: no error raised") + except ValueError: + print("missing-creds-ok") + except NotImplementedError: + print("missing-creds-FAIL: NotImplementedError instead of ValueError") + finally: + _restore_cloud_env(saved) + + +def _sandbox_stubs() -> None: + """Verify sandbox strategy methods raise NotImplementedError.""" + for provider in ("aws", "gcp", "azure"): + strategy = CloudSandboxStrategy(provider) + for method_name in ("create", "commit", "rollback"): + method = getattr(strategy, method_name) + try: + method("res-test", "plan-test") + print(f"sandbox-stubs-FAIL: {provider}.{method_name} no error") + return + except NotImplementedError: + pass + print("sandbox-stubs-ok") + + +def _aws_profile() -> None: + """Verify AWS profile name satisfies credential requirements.""" + saved = _clear_cloud_env() + try: + os.environ["AWS_PROFILE"] = "my-profile" + resolved = resolve_credentials("aws", {}) + errors = validate_credentials("aws", resolved) + assert len(errors) == 0, f"Profile should satisfy creds: {errors}" + print("aws-profile-ok") + finally: + _restore_cloud_env(saved) + + +def _builtin_types() -> None: + """Verify cloud hierarchy types are in BUILTIN_TYPES.""" + from cleveragents.application.services._resource_registry_data import ( + BUILTIN_TYPES, + ) + + names = [t["name"] for t in BUILTIN_TYPES] + # Core cloud types that must be present + for expected in ( + "cloud-account", + "cloud-region", + "cloud-network", + "aws-account", + "aws-region", + "aws-vpc", + "gcp", + "azure", + ): + assert expected in names, f"'{expected}' not in BUILTIN_TYPES" + print("builtin-types-ok") + + +_COMMANDS: dict[str, Any] = { + "protocol-check": _protocol_check, + "aws-resolve": _aws_resolve, + "gcp-resolve": _gcp_resolve, + "azure-resolve": _azure_resolve, + "stub-resolve": _stub_resolve, + "missing-creds": _missing_creds, + "sandbox-stubs": _sandbox_stubs, + "aws-profile": _aws_profile, + "builtin-types": _builtin_types, +} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(1) + _COMMANDS[sys.argv[1]]() diff --git a/src/cleveragents/application/services/_resource_registry_data.py b/src/cleveragents/application/services/_resource_registry_data.py index ab644bc1e..a141c68d3 100644 --- a/src/cleveragents/application/services/_resource_registry_data.py +++ b/src/cleveragents/application/services/_resource_registry_data.py @@ -38,6 +38,76 @@ __all__ = [ "spec_to_db", ] +# --------------------------------------------------------------------------- +# Shared constants +# --------------------------------------------------------------------------- + +_CLOUD_HANDLER = "cleveragents.resource.handlers.cloud:CloudResourceHandler" +_CLOUD_CAPS_RW: dict[str, bool] = { + "read": True, + "write": True, + "sandbox": False, + "checkpoint": False, +} +_CLOUD_CAPS_RO: dict[str, bool] = { + "read": True, + "write": False, + "sandbox": False, + "checkpoint": False, +} + + +def _cloud_base( + name: str, + description: str, + *, + child_types: list[str] | None = None, +) -> dict[str, Any]: + """Return a generic ``cloud-*`` abstract base type definition.""" + return { + "name": name, + "description": description, + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": False, + "built_in": True, + "cli_args": [], + "parent_types": [], + "child_types": child_types or [], + "handler": _CLOUD_HANDLER, + "capabilities": dict(_CLOUD_CAPS_RO), + } + + +def _aws_type( + name: str, + description: str, + *, + inherits: str | None = None, + parent_types: list[str] | None = None, + child_types: list[str] | None = None, + cli_args: list[dict[str, Any]] | None = None, + user_addable: bool = False, + capabilities: dict[str, bool] | None = None, +) -> dict[str, Any]: + """Return an ``aws-*`` provider-specific type definition.""" + d: dict[str, Any] = { + "name": name, + "description": description, + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": user_addable, + "built_in": True, + "cli_args": cli_args or [], + "parent_types": parent_types or [], + "child_types": child_types or [], + "handler": _CLOUD_HANDLER, + "capabilities": dict(capabilities or _CLOUD_CAPS_RO), + } + if inherits is not None: + d["inherits"] = inherits + return d + # --------------------------------------------------------------------------- # Built-in resource type definitions (registered at startup) @@ -45,7 +115,10 @@ __all__ = [ # IMPORTANT: Order matters — children must appear after their parents so # that ``bootstrap_builtin_types()`` can resolve ``inherits`` references. -BUILTIN_TYPES: list[dict[str, Any]] = [ + +# ===== Git / Filesystem / Container types (unchanged) ===== + +_GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [ { "name": "git-checkout", "description": "A local git checkout (cloned repository or worktree).", @@ -179,7 +252,7 @@ BUILTIN_TYPES: list[dict[str, Any]] = [ }, { "name": "devcontainer-file", - "description": ("A single devcontainer.json configuration file resource."), + "description": "A single devcontainer.json configuration file resource.", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "user_addable": False, @@ -199,6 +272,514 @@ BUILTIN_TYPES: list[dict[str, Any]] = [ ] +# ===== Generic cloud base types (provider-agnostic) ===== +# +# Abstract base types that define common cloud concepts. Provider- +# specific types (``aws-*``, ``azure-*``, etc.) inherit from these. +# Not user-addable — users create provider-specific account types. + +_CLOUD_BASE_TYPES: list[dict[str, Any]] = [ + # -- Account & region structure -- + _cloud_base( + "cloud-account", + "Abstract base: a cloud provider account or subscription.", + ), + _cloud_base( + "cloud-region", + "Abstract base: a geographic region within a cloud account.", + ), + # -- Networking -- + _cloud_base( + "cloud-network", + "Abstract base: a virtual network (VPC, VNet, etc.).", + ), + _cloud_base( + "cloud-subnet", + "Abstract base: a subnet within a virtual network.", + ), + _cloud_base( + "cloud-security-group", + "Abstract base: network security rules / firewall group.", + ), + _cloud_base( + "cloud-load-balancer", + "Abstract base: a network load balancer.", + ), + # -- Compute -- + _cloud_base( + "cloud-compute-instance", + "Abstract base: a virtual machine or compute instance.", + ), + # -- Storage -- + _cloud_base( + "cloud-object-store", + "Abstract base: object / blob storage bucket.", + ), + _cloud_base( + "cloud-block-storage", + "Abstract base: block storage volume.", + ), + # -- IAM -- + _cloud_base( + "cloud-identity-principal", + "Abstract base: IAM user or service principal.", + ), + _cloud_base( + "cloud-role", + "Abstract base: IAM role.", + ), + _cloud_base( + "cloud-policy", + "Abstract base: IAM or access policy document.", + ), + # -- Observability -- + _cloud_base( + "cloud-log-group", + "Abstract base: log aggregation group.", + ), + _cloud_base( + "cloud-alarm", + "Abstract base: monitoring alarm or alert.", + ), + # -- Messaging -- + _cloud_base( + "cloud-queue", + "Abstract base: message queue.", + ), + _cloud_base( + "cloud-topic", + "Abstract base: pub/sub notification topic.", + ), + # -- Containers -- + _cloud_base( + "cloud-container-repo", + "Abstract base: container image registry / repository.", + ), + _cloud_base( + "cloud-container-cluster", + "Abstract base: container orchestration cluster.", + ), + _cloud_base( + "cloud-container-service", + "Abstract base: container workload / service.", + ), +] + + +# ===== AWS provider types ===== +# +# Full AWS hierarchy inheriting from ``cloud-*`` generic bases. +# Only ``aws-account`` is user-addable (top-level entry point with +# credential CLI args). All other AWS types are children discovered +# or created within the account/region/VPC hierarchy. + +_AWS_TYPES: list[dict[str, Any]] = [ + # -- Account & region -- + _aws_type( + "aws-account", + "An Amazon Web Services account with credential configuration.", + inherits="cloud-account", + user_addable=True, + cli_args=[ + { + "name": "access-key-id", + "type": "string", + "required": False, + "description": "AWS access key ID.", + }, + { + "name": "secret-access-key", + "type": "string", + "required": False, + "description": "AWS secret access key.", + }, + { + "name": "session-token", + "type": "string", + "required": False, + "description": ( + "AWS session token (optional, for temporary credentials)." + ), + }, + { + "name": "region", + "type": "string", + "required": False, + "description": "Default AWS region (e.g. us-east-1).", + }, + { + "name": "profile", + "type": "string", + "required": False, + "description": "AWS profile name from ~/.aws/credentials.", + }, + ], + child_types=[ + "aws-region", + "aws-iam-user", + "aws-iam-role", + "aws-iam-policy", + "aws-iam-instance-profile", + ], + capabilities=_CLOUD_CAPS_RW, + ), + _aws_type( + "aws-region", + "An AWS geographic region (e.g. us-east-1).", + inherits="cloud-region", + parent_types=["aws-account"], + child_types=[ + "aws-vpc", + "aws-ec2-instance", + "aws-ami", + "aws-launch-template", + "aws-asg", + "aws-s3-bucket", + "aws-ebs-volume", + "aws-efs-filesystem", + "aws-cloudwatch-log-group", + "aws-cloudwatch-alarm", + "aws-cloudwatch-metric", + "aws-eventbridge-bus", + "aws-sqs-queue", + "aws-sns-topic", + "aws-ecr-repo", + "aws-ecs-cluster", + "aws-ecs-task-def", + "aws-eks-cluster", + ], + ), + # -- Networking -- + _aws_type( + "aws-vpc", + "An AWS Virtual Private Cloud network.", + inherits="cloud-network", + parent_types=["aws-region"], + child_types=[ + "aws-subnet", + "aws-igw", + "aws-nat-gw", + "aws-route-table", + "aws-nacl", + "aws-security-group", + "aws-alb", + "aws-nlb", + "aws-target-group", + ], + ), + _aws_type( + "aws-subnet", + "A subnet within an AWS VPC.", + inherits="cloud-subnet", + parent_types=["aws-vpc"], + child_types=["aws-ec2-instance", "aws-nat-gw"], + ), + _aws_type( + "aws-igw", + "An AWS Internet Gateway attached to a VPC.", + parent_types=["aws-vpc"], + ), + _aws_type( + "aws-nat-gw", + "An AWS NAT Gateway in a subnet.", + parent_types=["aws-subnet", "aws-vpc"], + ), + _aws_type( + "aws-route-table", + "An AWS VPC route table.", + parent_types=["aws-vpc"], + ), + _aws_type( + "aws-nacl", + "An AWS Network Access Control List.", + parent_types=["aws-vpc"], + ), + _aws_type( + "aws-security-group", + "An AWS security group (stateful firewall rules).", + inherits="cloud-security-group", + parent_types=["aws-vpc"], + ), + _aws_type( + "aws-alb", + "An AWS Application Load Balancer.", + inherits="cloud-load-balancer", + parent_types=["aws-vpc"], + child_types=["aws-listener"], + ), + _aws_type( + "aws-nlb", + "An AWS Network Load Balancer.", + inherits="cloud-load-balancer", + parent_types=["aws-vpc"], + child_types=["aws-listener"], + ), + _aws_type( + "aws-target-group", + "An AWS load balancer target group.", + parent_types=["aws-vpc"], + ), + _aws_type( + "aws-listener", + "An AWS load balancer listener rule.", + parent_types=["aws-alb", "aws-nlb"], + ), + # -- Compute -- + _aws_type( + "aws-ec2-instance", + "An AWS EC2 virtual machine instance.", + inherits="cloud-compute-instance", + parent_types=["aws-subnet", "aws-region"], + ), + _aws_type( + "aws-ami", + "An AWS Amazon Machine Image.", + parent_types=["aws-region"], + ), + _aws_type( + "aws-launch-template", + "An AWS EC2 launch template.", + parent_types=["aws-region"], + ), + _aws_type( + "aws-asg", + "An AWS Auto Scaling Group.", + parent_types=["aws-region"], + ), + # -- Storage -- + _aws_type( + "aws-s3-bucket", + "An AWS S3 object storage bucket.", + inherits="cloud-object-store", + parent_types=["aws-region"], + ), + _aws_type( + "aws-ebs-volume", + "An AWS Elastic Block Store volume.", + inherits="cloud-block-storage", + parent_types=["aws-region"], + ), + _aws_type( + "aws-efs-filesystem", + "An AWS Elastic File System.", + parent_types=["aws-region"], + ), + # -- IAM -- + _aws_type( + "aws-iam-user", + "An AWS IAM user.", + inherits="cloud-identity-principal", + parent_types=["aws-account"], + ), + _aws_type( + "aws-iam-role", + "An AWS IAM role.", + inherits="cloud-role", + parent_types=["aws-account"], + ), + _aws_type( + "aws-iam-policy", + "An AWS IAM managed policy.", + inherits="cloud-policy", + parent_types=["aws-account"], + ), + _aws_type( + "aws-iam-instance-profile", + "An AWS IAM instance profile (role wrapper for EC2).", + parent_types=["aws-account"], + ), + # -- Observability -- + _aws_type( + "aws-cloudwatch-log-group", + "An AWS CloudWatch Logs log group.", + inherits="cloud-log-group", + parent_types=["aws-region"], + ), + _aws_type( + "aws-cloudwatch-alarm", + "An AWS CloudWatch alarm.", + inherits="cloud-alarm", + parent_types=["aws-region"], + ), + _aws_type( + "aws-cloudwatch-metric", + "An AWS CloudWatch custom or service metric.", + parent_types=["aws-region"], + ), + _aws_type( + "aws-eventbridge-bus", + "An AWS EventBridge event bus.", + parent_types=["aws-region"], + child_types=["aws-eventbridge-rule"], + ), + _aws_type( + "aws-eventbridge-rule", + "An AWS EventBridge rule.", + parent_types=["aws-eventbridge-bus"], + child_types=["aws-eventbridge-target"], + ), + _aws_type( + "aws-eventbridge-target", + "An AWS EventBridge rule target.", + parent_types=["aws-eventbridge-rule"], + ), + # -- Messaging -- + _aws_type( + "aws-sqs-queue", + "An AWS SQS message queue.", + inherits="cloud-queue", + parent_types=["aws-region"], + ), + _aws_type( + "aws-sns-topic", + "An AWS SNS notification topic.", + inherits="cloud-topic", + parent_types=["aws-region"], + child_types=["aws-sns-subscription"], + ), + _aws_type( + "aws-sns-subscription", + "An AWS SNS topic subscription.", + parent_types=["aws-sns-topic"], + ), + # -- Containers -- + _aws_type( + "aws-ecr-repo", + "An AWS Elastic Container Registry repository.", + inherits="cloud-container-repo", + parent_types=["aws-region"], + ), + _aws_type( + "aws-ecs-cluster", + "An AWS Elastic Container Service cluster.", + inherits="cloud-container-cluster", + parent_types=["aws-region"], + child_types=["aws-ecs-service"], + ), + _aws_type( + "aws-ecs-service", + "An AWS ECS service (running task instances).", + inherits="cloud-container-service", + parent_types=["aws-ecs-cluster"], + ), + _aws_type( + "aws-ecs-task-def", + "An AWS ECS task definition.", + parent_types=["aws-region"], + ), + _aws_type( + "aws-eks-cluster", + "An AWS Elastic Kubernetes Service cluster.", + inherits="cloud-container-cluster", + parent_types=["aws-region"], + child_types=["aws-eks-nodegroup"], + ), + _aws_type( + "aws-eks-nodegroup", + "An AWS EKS managed node group.", + parent_types=["aws-eks-cluster"], + ), +] + + +# ===== GCP / Azure placeholders ===== +# +# Flat provider-level types kept for forward compatibility. Full +# hierarchies for these providers are deferred to future PRs. + +_GCP_AZURE_TYPES: list[dict[str, Any]] = [ + { + "name": "gcp", + "description": "Google Cloud Platform resource (hierarchy pending).", + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": True, + "built_in": True, + "inherits": "cloud-account", + "cli_args": [ + { + "name": "service-account-json-path", + "type": "string", + "required": False, + "description": "Path to GCP service account JSON key file.", + }, + { + "name": "project-id", + "type": "string", + "required": False, + "description": "GCP project ID.", + }, + { + "name": "region", + "type": "string", + "required": False, + "description": "GCP region (e.g. us-central1).", + }, + ], + "parent_types": [], + "child_types": [], + "handler": _CLOUD_HANDLER, + "capabilities": dict(_CLOUD_CAPS_RW), + }, + { + "name": "azure", + "description": "Microsoft Azure cloud resource (hierarchy pending).", + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": True, + "built_in": True, + "inherits": "cloud-account", + "cli_args": [ + { + "name": "subscription-id", + "type": "string", + "required": False, + "description": "Azure subscription ID.", + }, + { + "name": "tenant-id", + "type": "string", + "required": False, + "description": "Azure Active Directory tenant ID.", + }, + { + "name": "client-id", + "type": "string", + "required": False, + "description": "Azure service principal client ID.", + }, + { + "name": "client-secret", + "type": "string", + "required": False, + "description": "Azure service principal client secret.", + }, + { + "name": "region", + "type": "string", + "required": False, + "description": "Azure region (e.g. eastus).", + }, + ], + "parent_types": [], + "child_types": [], + "handler": _CLOUD_HANDLER, + "capabilities": dict(_CLOUD_CAPS_RW), + }, +] + + +# ===== Assemble final list ===== + +BUILTIN_TYPES: list[dict[str, Any]] = [ + *_GIT_FS_CONTAINER_TYPES, + *_CLOUD_BASE_TYPES, + *_AWS_TYPES, + *_GCP_AZURE_TYPES, + *DATABASE_TYPE_DEFS, +] + + # --------------------------------------------------------------------------- # DB ↔ domain conversion helpers # --------------------------------------------------------------------------- diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index 0e9883567..f0e0b2565 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -155,6 +155,7 @@ class ResourceTypeSpec(BaseModel): # Built-in resource type names (unnamespaced) BUILTIN_NAMES: ClassVar[frozenset[str]] = frozenset( { + # -- Git / Filesystem / Container -- "git-checkout", "fs-directory", "fs-mount", @@ -166,6 +167,69 @@ class ResourceTypeSpec(BaseModel): "mysql", "sqlite", "duckdb", + # -- Generic cloud base types -- + "cloud-account", + "cloud-region", + "cloud-network", + "cloud-subnet", + "cloud-security-group", + "cloud-load-balancer", + "cloud-compute-instance", + "cloud-object-store", + "cloud-block-storage", + "cloud-identity-principal", + "cloud-role", + "cloud-policy", + "cloud-log-group", + "cloud-alarm", + "cloud-queue", + "cloud-topic", + "cloud-container-repo", + "cloud-container-cluster", + "cloud-container-service", + # -- AWS provider types -- + "aws-account", + "aws-region", + "aws-vpc", + "aws-subnet", + "aws-igw", + "aws-nat-gw", + "aws-route-table", + "aws-nacl", + "aws-security-group", + "aws-alb", + "aws-nlb", + "aws-target-group", + "aws-listener", + "aws-ec2-instance", + "aws-ami", + "aws-launch-template", + "aws-asg", + "aws-s3-bucket", + "aws-ebs-volume", + "aws-efs-filesystem", + "aws-iam-user", + "aws-iam-role", + "aws-iam-policy", + "aws-iam-instance-profile", + "aws-cloudwatch-log-group", + "aws-cloudwatch-alarm", + "aws-cloudwatch-metric", + "aws-eventbridge-bus", + "aws-eventbridge-rule", + "aws-eventbridge-target", + "aws-sqs-queue", + "aws-sns-topic", + "aws-sns-subscription", + "aws-ecr-repo", + "aws-ecs-cluster", + "aws-ecs-service", + "aws-ecs-task-def", + "aws-eks-cluster", + "aws-eks-nodegroup", + # -- GCP / Azure (flat, hierarchy pending) -- + "gcp", + "azure", } ) diff --git a/src/cleveragents/resource/handlers/__init__.py b/src/cleveragents/resource/handlers/__init__.py index 8cf953ffd..56ada49e6 100644 --- a/src/cleveragents/resource/handlers/__init__.py +++ b/src/cleveragents/resource/handlers/__init__.py @@ -14,11 +14,13 @@ which defines a single ``resolve`` method returning a ## Built-in Handlers -| Handler | Resource Type | Sandbox Strategy | -|--------------------------|--------------------------|------------------| -| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` | -| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write``| -| ``DevcontainerHandler`` | ``devcontainer-instance``| ``snapshot`` | +| Handler | Resource Type(s) | Strategy | +|--------------------------|---------------------------|----------------------| +| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` | +| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write`` | +| ``DevcontainerHandler`` | ``devcontainer-instance`` | ``snapshot`` | +| ``CloudResourceHandler`` | ``cloud-*``, ``aws-*`` | ``none`` | +| ``DatabaseHandler`` | ``postgres``, ``mysql`` | ``transaction`` | ## Handler Resolution @@ -27,6 +29,7 @@ Handler strings stored on :class:`ResourceTypeSpec` use the format dynamically imports the module and returns an instance. """ +from cleveragents.resource.handlers.cloud import CloudResourceHandler from cleveragents.resource.handlers.database import DatabaseResourceHandler from cleveragents.resource.handlers.devcontainer import DevcontainerHandler from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler @@ -38,6 +41,7 @@ from cleveragents.resource.handlers.resolver import ( ) __all__ = [ + "CloudResourceHandler", "DatabaseResourceHandler", "DevcontainerHandler", "FsDirectoryHandler", diff --git a/src/cleveragents/resource/handlers/cloud.py b/src/cleveragents/resource/handlers/cloud.py new file mode 100644 index 000000000..3a0844876 --- /dev/null +++ b/src/cleveragents/resource/handlers/cloud.py @@ -0,0 +1,530 @@ +"""Cloud infrastructure resource handler for CleverAgents. + +Provides the :class:`CloudResourceHandler` for cloud infrastructure +resource types. The handler supports a hierarchical type model where +provider-specific types (``aws-account``, ``aws-vpc``, etc.) inherit +from generic ``cloud-*`` base types. + +This handler validates configuration and resolves credentials from +environment variables and profile names but does **not** execute any +cloud SDK operations -- actual execution raises +:exc:`NotImplementedError`. + +Cloud resource types are registered as built-in types at bootstrap and +use ``sandbox_strategy = "none"`` because cloud sandbox isolation is +not yet implemented. + +## Provider Detection + +The handler extracts the cloud provider from the resource type name: + +- ``aws-*`` or ``aws`` → AWS provider +- ``gcp-*`` or ``gcp`` → GCP provider +- ``azure-*`` or ``azure`` → Azure provider +- ``cloud-*`` → generic (no provider-specific validation) + +## Credential Resolution + +Credentials are resolved in this priority order: + +1. Explicit configuration values (passed via ``properties``) +2. Environment variables +3. Profile names (AWS only -- ``AWS_PROFILE``) + +No cloud SDK dependencies are required. Credential values are never +logged; the existing :mod:`cleveragents.shared.redaction` patterns +handle masking. + +Based on: + - Issue #343: Cloud Infrastructure Resources + - implementation_plan.md group M7.post-resource-cloud +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +from cleveragents.domain.models.core.resource import Resource +from cleveragents.infrastructure.sandbox.manager import SandboxManager +from cleveragents.shared.redaction import REDACTED, is_sensitive_key +from cleveragents.tool.context import BoundResource + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Cloud credential field definitions +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _CredentialField: + """Metadata for a single credential or configuration field.""" + + name: str + env_var: str + required: bool = True + sensitive: bool = False + description: str = "" + + +@dataclass(frozen=True) +class CloudProviderSpec: + """Specification of a cloud provider's credential and config fields.""" + + provider: str + description: str + credential_fields: tuple[_CredentialField, ...] + metadata_fields: tuple[_CredentialField, ...] = () + profile_env_var: str | None = None + required_fields: frozenset[str] = field(default_factory=frozenset) + + @property + def all_fields(self) -> tuple[_CredentialField, ...]: + """Return all fields (credential + metadata).""" + return self.credential_fields + self.metadata_fields + + +# --------------------------------------------------------------------------- +# Provider specifications +# --------------------------------------------------------------------------- + + +AWS_SPEC = CloudProviderSpec( + provider="aws", + description="Amazon Web Services cloud resource.", + credential_fields=( + _CredentialField( + name="access-key-id", + env_var="AWS_ACCESS_KEY_ID", + required=True, + sensitive=True, + description="AWS access key ID.", + ), + _CredentialField( + name="secret-access-key", + env_var="AWS_SECRET_ACCESS_KEY", + required=True, + sensitive=True, + description="AWS secret access key.", + ), + _CredentialField( + name="session-token", + env_var="AWS_SESSION_TOKEN", + required=False, + sensitive=True, + description="AWS session token (optional, for temporary credentials).", + ), + ), + metadata_fields=( + _CredentialField( + name="region", + env_var="AWS_REGION", + required=False, + sensitive=False, + description="AWS region (e.g. us-east-1).", + ), + _CredentialField( + name="profile", + env_var="AWS_PROFILE", + required=False, + sensitive=False, + description="AWS profile name from ~/.aws/credentials.", + ), + ), + profile_env_var="AWS_PROFILE", + required_fields=frozenset({"access-key-id", "secret-access-key"}), +) + +GCP_SPEC = CloudProviderSpec( + provider="gcp", + description="Google Cloud Platform resource.", + credential_fields=( + _CredentialField( + name="service-account-json-path", + env_var="GOOGLE_APPLICATION_CREDENTIALS", + required=True, + sensitive=True, + description="Path to GCP service account JSON key file.", + ), + ), + metadata_fields=( + _CredentialField( + name="project-id", + env_var="GCLOUD_PROJECT", + required=True, + sensitive=False, + description="GCP project ID.", + ), + _CredentialField( + name="region", + env_var="GCP_REGION", + required=False, + sensitive=False, + description="GCP region (e.g. us-central1).", + ), + ), + required_fields=frozenset({"service-account-json-path", "project-id"}), +) + +AZURE_SPEC = CloudProviderSpec( + provider="azure", + description="Microsoft Azure cloud resource.", + credential_fields=( + _CredentialField( + name="subscription-id", + env_var="AZURE_SUBSCRIPTION_ID", + required=True, + sensitive=True, + description="Azure subscription ID.", + ), + _CredentialField( + name="tenant-id", + env_var="AZURE_TENANT_ID", + required=True, + sensitive=True, + description="Azure Active Directory tenant ID.", + ), + _CredentialField( + name="client-id", + env_var="AZURE_CLIENT_ID", + required=True, + sensitive=True, + description="Azure service principal client ID.", + ), + _CredentialField( + name="client-secret", + env_var="AZURE_CLIENT_SECRET", + required=True, + sensitive=True, + description="Azure service principal client secret.", + ), + ), + metadata_fields=( + _CredentialField( + name="region", + env_var="AZURE_REGION", + required=False, + sensitive=False, + description="Azure region (e.g. eastus).", + ), + ), + required_fields=frozenset( + {"subscription-id", "tenant-id", "client-id", "client-secret"} + ), +) + + +#: Mapping from provider name to its specification. +CLOUD_PROVIDERS: dict[str, CloudProviderSpec] = { + "aws": AWS_SPEC, + "gcp": GCP_SPEC, + "azure": AZURE_SPEC, +} + +#: Provider prefixes for hierarchical type name detection. +_PROVIDER_PREFIXES: tuple[str, ...] = ("aws-", "gcp-", "azure-") + + +def extract_provider(type_name: str) -> str | None: + """Extract the cloud provider key from a resource type name. + + Supports both flat names (``aws``, ``gcp``, ``azure``) and + hierarchical names (``aws-account``, ``aws-vpc``, etc.). + Returns ``None`` for generic ``cloud-*`` base types. + + Args: + type_name: Resource type name. + + Returns: + Provider key (``aws``, ``gcp``, ``azure``) or ``None``. + """ + # Direct match (flat provider names) + if type_name in CLOUD_PROVIDERS: + return type_name + # Hierarchical names: aws-*, gcp-*, azure-* + for prefix in _PROVIDER_PREFIXES: + if type_name.startswith(prefix): + return prefix.rstrip("-") + # Generic cloud-* base types have no specific provider + return None + + +# --------------------------------------------------------------------------- +# Credential resolution +# --------------------------------------------------------------------------- + + +def _safe_value_repr(field_def: _CredentialField, value: str | None) -> str: + """Return a safe representation of a credential value for logging.""" + if value is None: + return "" + if field_def.sensitive or is_sensitive_key(field_def.name): + return REDACTED + return value + + +def resolve_credentials( + provider: str, + properties: dict[str, Any], +) -> dict[str, str | None]: + """Resolve credentials for a cloud provider from properties and env vars. + + Resolution priority: + 1. Explicit values in ``properties`` + 2. Environment variables + + Args: + provider: Cloud provider name (``aws``, ``gcp``, ``azure``). + properties: Resource properties dict with explicit values. + + Returns: + Dict mapping field names to resolved values (``None`` if unresolved). + + Raises: + ValueError: If the provider name is not recognised. + """ + spec = CLOUD_PROVIDERS.get(provider) + if spec is None: + raise ValueError( + f"Unknown cloud provider '{provider}'. " + f"Supported providers: {', '.join(sorted(CLOUD_PROVIDERS))}." + ) + + resolved: dict[str, str | None] = {} + for field_def in spec.all_fields: + # Priority 1: explicit property + explicit = properties.get(field_def.name) + if explicit is not None and str(explicit).strip(): + resolved[field_def.name] = str(explicit) + continue + + # Priority 2: environment variable + env_val = os.environ.get(field_def.env_var) + if env_val is not None and env_val.strip(): + resolved[field_def.name] = env_val + continue + + resolved[field_def.name] = None + + return resolved + + +def validate_credentials( + provider: str, + resolved: dict[str, str | None], +) -> list[str]: + """Validate that all required credentials are present. + + Args: + provider: Cloud provider name. + resolved: Dict from :func:`resolve_credentials`. + + Returns: + List of error messages (empty if valid). + """ + spec = CLOUD_PROVIDERS.get(provider) + if spec is None: + return [ + f"Unknown cloud provider '{provider}'. " + f"Supported providers: {', '.join(sorted(CLOUD_PROVIDERS))}." + ] + + # Check if a profile is set (AWS only) -- profile can satisfy + # required credential fields + has_profile = False + if spec.profile_env_var is not None: + profile_val = resolved.get("profile") + if profile_val is not None and profile_val.strip(): + has_profile = True + + errors: list[str] = [] + for field_def in spec.all_fields: + if not field_def.required: + continue + value = resolved.get(field_def.name) + # If a profile is configured, skip credential-field checks + # (the SDK will resolve them from the profile). + if has_profile and field_def in spec.credential_fields: + continue + if value is None: + safe_repr = _safe_value_repr(field_def, value) + errors.append( + f"Required field '{field_def.name}' for provider " + f"'{provider}' is missing (current value: {safe_repr}). " + f"Set it via resource properties or the " + f"{field_def.env_var} environment variable." + ) + + return errors + + +# --------------------------------------------------------------------------- +# CloudResourceHandler +# --------------------------------------------------------------------------- + + +class CloudResourceHandler: + """Handler for cloud infrastructure resource types. + + Validates cloud resource configuration and resolves credentials + from environment variables and profile names. Actual cloud + operations are **not** implemented -- calling :meth:`resolve` + raises :exc:`NotImplementedError` after validation. + + Supports hierarchical type names (``aws-account``, ``aws-vpc``, + etc.) in addition to flat provider names (``aws``, ``gcp``, + ``azure``). Generic ``cloud-*`` base types skip provider-specific + credential validation. + + This handler satisfies the + :class:`~cleveragents.resource.handlers.protocol.ResourceHandler` + protocol. + """ + + def resolve( + self, + *, + resource: Resource, + plan_id: str, + slot_name: str, + sandbox_manager: SandboxManager, + access: str = "read_only", + ) -> BoundResource: + """Validate cloud resource configuration and raise. + + Performs full credential validation and resolution, then + raises :exc:`NotImplementedError` because cloud sandbox + execution is not yet implemented. + + Args: + resource: A cloud resource instance. + plan_id: The plan requesting the sandbox. + slot_name: Name of the tool resource slot being filled. + sandbox_manager: The sandbox lifecycle manager (unused). + access: Access mode (unused). + + Raises: + ValueError: If required credentials are missing or the + resource type is not a supported cloud provider. + NotImplementedError: Always, after successful validation. + """ + type_name = resource.resource_type_name + provider = extract_provider(type_name) + + if provider is None: + # Generic cloud-* base type — no provider-specific validation. + raise NotImplementedError( + f"Cloud resource execution for generic type '{type_name}' " + f"is not yet implemented. Resource '{resource.resource_id}' " + f"(plan={plan_id}, slot={slot_name}). " + f"Generic cloud base types cannot be resolved directly." + ) + + if provider not in CLOUD_PROVIDERS: + raise ValueError( + f"Resource type '{type_name}' maps to unknown provider " + f"'{provider}'. " + f"Supported: {', '.join(sorted(CLOUD_PROVIDERS))}." + ) + + # Resolve credentials + resolved = resolve_credentials(provider, dict(resource.properties)) + + # Log resolution (with redaction) + for field_def in CLOUD_PROVIDERS[provider].all_fields: + safe = _safe_value_repr(field_def, resolved.get(field_def.name)) + logger.debug( + "Cloud credential %s/%s = %s", + provider, + field_def.name, + safe, + ) + + # Validate credentials (only for account-level types that carry creds) + is_account_type = type_name in ( + "aws", + "gcp", + "azure", + "aws-account", + ) + if is_account_type: + errors = validate_credentials(provider, resolved) + if errors: + raise ValueError( + f"Cloud resource validation failed for '{type_name}' " + f"(provider={provider}): " + "; ".join(errors) + ) + + raise NotImplementedError( + f"Cloud resource execution for '{type_name}' " + f"(provider={provider}) is not yet implemented. " + f"Resource '{resource.resource_id}' " + f"(plan={plan_id}, slot={slot_name}) validated successfully " + f"but sandbox provisioning for cloud resources is pending." + ) + + +# --------------------------------------------------------------------------- +# Stubbed sandbox strategies +# --------------------------------------------------------------------------- + + +class CloudSandboxStrategy: + """Stubbed sandbox strategy for cloud resources. + + Validates configuration but raises :exc:`NotImplementedError` for + all lifecycle operations (create, commit, rollback). This is the + expected behaviour per the issue specification. + """ + + def __init__(self, provider: str) -> None: + self._provider = provider + + def validate(self, properties: dict[str, Any]) -> list[str]: + """Validate cloud resource configuration. + + Args: + properties: Resource properties dict. + + Returns: + List of validation error messages (empty if valid). + """ + resolved = resolve_credentials(self._provider, properties) + return validate_credentials(self._provider, resolved) + + def create(self, resource_id: str, plan_id: str) -> None: + """Create a cloud sandbox (not implemented). + + Raises: + NotImplementedError: Always. + """ + raise NotImplementedError( + f"Cloud sandbox creation for provider '{self._provider}' " + f"is not yet implemented (resource={resource_id}, plan={plan_id})." + ) + + def commit(self, resource_id: str, plan_id: str) -> None: + """Commit a cloud sandbox (not implemented). + + Raises: + NotImplementedError: Always. + """ + raise NotImplementedError( + f"Cloud sandbox commit for provider '{self._provider}' " + f"is not yet implemented (resource={resource_id}, plan={plan_id})." + ) + + def rollback(self, resource_id: str, plan_id: str) -> None: + """Rollback a cloud sandbox (not implemented). + + Raises: + NotImplementedError: Always. + """ + raise NotImplementedError( + f"Cloud sandbox rollback for provider '{self._provider}' " + f"is not yet implemented (resource={resource_id}, plan={plan_id})." + )