feat(resource): add cloud infrastructure resources
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / lint (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 59s
CI / security (pull_request) Successful in 59s
CI / unit_tests (pull_request) Successful in 3m9s
CI / integration_tests (pull_request) Successful in 3m34s
CI / e2e_tests (pull_request) Successful in 3m54s
CI / docker (pull_request) Successful in 55s
CI / coverage (pull_request) Successful in 6m1s
CI / build (push) Successful in 15s
CI / lint (push) Successful in 16s
CI / quality (push) Successful in 27s
CI / typecheck (push) Successful in 38s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 48s
CI / unit_tests (push) Successful in 3m14s
CI / integration_tests (push) Successful in 3m30s
CI / docker (push) Successful in 56s
CI / e2e_tests (push) Successful in 4m43s
CI / coverage (push) Successful in 6m1s
CI / benchmark-publish (push) Successful in 19m50s
CI / benchmark-regression (pull_request) Successful in 37m17s

Implement cloud resource types (aws, gcp, azure) with credential
fields, region/tenant metadata, and stubbed sandbox strategies.
Credential resolution uses environment variables and profile names
with no secrets logged.

Key changes:
- Add CloudResourceHandler with aws/gcp/azure type definitions
- Add credential resolution from env vars and profile names
- Add stubbed sandbox strategies (validate config, raise NotImplementedError)
- Register cloud types in bootstrap_builtin_types
- Credential masking via existing redaction patterns
- Add Behave BDD tests, Robot integration tests, ASV benchmarks

ISSUES CLOSED: #343
This commit was merged in pull request #669.
This commit is contained in:
2026-03-10 06:25:28 +00:00
parent ff2d824f17
commit c65e8a5285
24 changed files with 2700 additions and 47 deletions
@@ -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"
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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``.
+3 -3
View File
@@ -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:
-2
View File
@@ -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
+4 -2
View File
@@ -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
# ---------------------------------------------------------------------------
+11 -9
View File
@@ -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
+5 -5
View File
@@ -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,
+196
View File
@@ -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({})
+1 -2
View File
@@ -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,
-1
View File
@@ -41,7 +41,6 @@ from cleveragents.resource.handlers.database import ( # noqa: E402
validate_connection,
)
# ---------------------------------------------------------------------------
# Type definition benchmarks
# ---------------------------------------------------------------------------
+3 -2
View File
@@ -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).
+2 -3
View File
@@ -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:
+166
View File
@@ -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
+94
View File
@@ -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`.
+245
View File
@@ -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
+434
View File
@@ -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"
)
+6 -7
View File
@@ -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'<a href="{link}">ADR-{superseded_by:03d}</a>'
)
else:
superseded_part = f' <span class="adr-label">— superseded by ADR-{superseded_by:03d}</span>'
superseded_part = (
f' <span class="adr-label">'
f"— superseded by ADR-{superseded_by:03d}</span>"
)
line2 = (
f'<div class="adr-header-line">'
@@ -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(
+81
View File
@@ -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
+259
View File
@@ -0,0 +1,259 @@
"""Helper utilities for cloud resource Robot smoke tests.
Each command prints a single ``<command>-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]]()
@@ -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
# ---------------------------------------------------------------------------
@@ -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",
}
)
@@ -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",
+530
View File
@@ -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 "<not set>"
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})."
)