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
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:
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({})
|
||||
@@ -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,
|
||||
|
||||
@@ -41,7 +41,6 @@ from cleveragents.resource.handlers.database import ( # noqa: E402
|
||||
validate_connection,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type definition benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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})."
|
||||
)
|
||||
Reference in New Issue
Block a user