feat(resource): add cloud infrastructure resources

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 is contained in:
2026-03-10 06:25:28 +00:00
parent ff2d824f17
commit c65e8a5285
24 changed files with 2700 additions and 47 deletions
@@ -38,6 +38,76 @@ __all__ = [
"spec_to_db",
]
# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------
_CLOUD_HANDLER = "cleveragents.resource.handlers.cloud:CloudResourceHandler"
_CLOUD_CAPS_RW: dict[str, bool] = {
"read": True,
"write": True,
"sandbox": False,
"checkpoint": False,
}
_CLOUD_CAPS_RO: dict[str, bool] = {
"read": True,
"write": False,
"sandbox": False,
"checkpoint": False,
}
def _cloud_base(
name: str,
description: str,
*,
child_types: list[str] | None = None,
) -> dict[str, Any]:
"""Return a generic ``cloud-*`` abstract base type definition."""
return {
"name": name,
"description": description,
"resource_kind": "physical",
"sandbox_strategy": "none",
"user_addable": False,
"built_in": True,
"cli_args": [],
"parent_types": [],
"child_types": child_types or [],
"handler": _CLOUD_HANDLER,
"capabilities": dict(_CLOUD_CAPS_RO),
}
def _aws_type(
name: str,
description: str,
*,
inherits: str | None = None,
parent_types: list[str] | None = None,
child_types: list[str] | None = None,
cli_args: list[dict[str, Any]] | None = None,
user_addable: bool = False,
capabilities: dict[str, bool] | None = None,
) -> dict[str, Any]:
"""Return an ``aws-*`` provider-specific type definition."""
d: dict[str, Any] = {
"name": name,
"description": description,
"resource_kind": "physical",
"sandbox_strategy": "none",
"user_addable": user_addable,
"built_in": True,
"cli_args": cli_args or [],
"parent_types": parent_types or [],
"child_types": child_types or [],
"handler": _CLOUD_HANDLER,
"capabilities": dict(capabilities or _CLOUD_CAPS_RO),
}
if inherits is not None:
d["inherits"] = inherits
return d
# ---------------------------------------------------------------------------
# Built-in resource type definitions (registered at startup)
@@ -45,7 +115,10 @@ __all__ = [
# IMPORTANT: Order matters — children must appear after their parents so
# that ``bootstrap_builtin_types()`` can resolve ``inherits`` references.
BUILTIN_TYPES: list[dict[str, Any]] = [
# ===== Git / Filesystem / Container types (unchanged) =====
_GIT_FS_CONTAINER_TYPES: list[dict[str, Any]] = [
{
"name": "git-checkout",
"description": "A local git checkout (cloned repository or worktree).",
@@ -179,7 +252,7 @@ BUILTIN_TYPES: list[dict[str, Any]] = [
},
{
"name": "devcontainer-file",
"description": ("A single devcontainer.json configuration file resource."),
"description": "A single devcontainer.json configuration file resource.",
"resource_kind": "physical",
"sandbox_strategy": "copy_on_write",
"user_addable": False,
@@ -199,6 +272,514 @@ BUILTIN_TYPES: list[dict[str, Any]] = [
]
# ===== Generic cloud base types (provider-agnostic) =====
#
# Abstract base types that define common cloud concepts. Provider-
# specific types (``aws-*``, ``azure-*``, etc.) inherit from these.
# Not user-addable — users create provider-specific account types.
_CLOUD_BASE_TYPES: list[dict[str, Any]] = [
# -- Account & region structure --
_cloud_base(
"cloud-account",
"Abstract base: a cloud provider account or subscription.",
),
_cloud_base(
"cloud-region",
"Abstract base: a geographic region within a cloud account.",
),
# -- Networking --
_cloud_base(
"cloud-network",
"Abstract base: a virtual network (VPC, VNet, etc.).",
),
_cloud_base(
"cloud-subnet",
"Abstract base: a subnet within a virtual network.",
),
_cloud_base(
"cloud-security-group",
"Abstract base: network security rules / firewall group.",
),
_cloud_base(
"cloud-load-balancer",
"Abstract base: a network load balancer.",
),
# -- Compute --
_cloud_base(
"cloud-compute-instance",
"Abstract base: a virtual machine or compute instance.",
),
# -- Storage --
_cloud_base(
"cloud-object-store",
"Abstract base: object / blob storage bucket.",
),
_cloud_base(
"cloud-block-storage",
"Abstract base: block storage volume.",
),
# -- IAM --
_cloud_base(
"cloud-identity-principal",
"Abstract base: IAM user or service principal.",
),
_cloud_base(
"cloud-role",
"Abstract base: IAM role.",
),
_cloud_base(
"cloud-policy",
"Abstract base: IAM or access policy document.",
),
# -- Observability --
_cloud_base(
"cloud-log-group",
"Abstract base: log aggregation group.",
),
_cloud_base(
"cloud-alarm",
"Abstract base: monitoring alarm or alert.",
),
# -- Messaging --
_cloud_base(
"cloud-queue",
"Abstract base: message queue.",
),
_cloud_base(
"cloud-topic",
"Abstract base: pub/sub notification topic.",
),
# -- Containers --
_cloud_base(
"cloud-container-repo",
"Abstract base: container image registry / repository.",
),
_cloud_base(
"cloud-container-cluster",
"Abstract base: container orchestration cluster.",
),
_cloud_base(
"cloud-container-service",
"Abstract base: container workload / service.",
),
]
# ===== AWS provider types =====
#
# Full AWS hierarchy inheriting from ``cloud-*`` generic bases.
# Only ``aws-account`` is user-addable (top-level entry point with
# credential CLI args). All other AWS types are children discovered
# or created within the account/region/VPC hierarchy.
_AWS_TYPES: list[dict[str, Any]] = [
# -- Account & region --
_aws_type(
"aws-account",
"An Amazon Web Services account with credential configuration.",
inherits="cloud-account",
user_addable=True,
cli_args=[
{
"name": "access-key-id",
"type": "string",
"required": False,
"description": "AWS access key ID.",
},
{
"name": "secret-access-key",
"type": "string",
"required": False,
"description": "AWS secret access key.",
},
{
"name": "session-token",
"type": "string",
"required": False,
"description": (
"AWS session token (optional, for temporary credentials)."
),
},
{
"name": "region",
"type": "string",
"required": False,
"description": "Default AWS region (e.g. us-east-1).",
},
{
"name": "profile",
"type": "string",
"required": False,
"description": "AWS profile name from ~/.aws/credentials.",
},
],
child_types=[
"aws-region",
"aws-iam-user",
"aws-iam-role",
"aws-iam-policy",
"aws-iam-instance-profile",
],
capabilities=_CLOUD_CAPS_RW,
),
_aws_type(
"aws-region",
"An AWS geographic region (e.g. us-east-1).",
inherits="cloud-region",
parent_types=["aws-account"],
child_types=[
"aws-vpc",
"aws-ec2-instance",
"aws-ami",
"aws-launch-template",
"aws-asg",
"aws-s3-bucket",
"aws-ebs-volume",
"aws-efs-filesystem",
"aws-cloudwatch-log-group",
"aws-cloudwatch-alarm",
"aws-cloudwatch-metric",
"aws-eventbridge-bus",
"aws-sqs-queue",
"aws-sns-topic",
"aws-ecr-repo",
"aws-ecs-cluster",
"aws-ecs-task-def",
"aws-eks-cluster",
],
),
# -- Networking --
_aws_type(
"aws-vpc",
"An AWS Virtual Private Cloud network.",
inherits="cloud-network",
parent_types=["aws-region"],
child_types=[
"aws-subnet",
"aws-igw",
"aws-nat-gw",
"aws-route-table",
"aws-nacl",
"aws-security-group",
"aws-alb",
"aws-nlb",
"aws-target-group",
],
),
_aws_type(
"aws-subnet",
"A subnet within an AWS VPC.",
inherits="cloud-subnet",
parent_types=["aws-vpc"],
child_types=["aws-ec2-instance", "aws-nat-gw"],
),
_aws_type(
"aws-igw",
"An AWS Internet Gateway attached to a VPC.",
parent_types=["aws-vpc"],
),
_aws_type(
"aws-nat-gw",
"An AWS NAT Gateway in a subnet.",
parent_types=["aws-subnet", "aws-vpc"],
),
_aws_type(
"aws-route-table",
"An AWS VPC route table.",
parent_types=["aws-vpc"],
),
_aws_type(
"aws-nacl",
"An AWS Network Access Control List.",
parent_types=["aws-vpc"],
),
_aws_type(
"aws-security-group",
"An AWS security group (stateful firewall rules).",
inherits="cloud-security-group",
parent_types=["aws-vpc"],
),
_aws_type(
"aws-alb",
"An AWS Application Load Balancer.",
inherits="cloud-load-balancer",
parent_types=["aws-vpc"],
child_types=["aws-listener"],
),
_aws_type(
"aws-nlb",
"An AWS Network Load Balancer.",
inherits="cloud-load-balancer",
parent_types=["aws-vpc"],
child_types=["aws-listener"],
),
_aws_type(
"aws-target-group",
"An AWS load balancer target group.",
parent_types=["aws-vpc"],
),
_aws_type(
"aws-listener",
"An AWS load balancer listener rule.",
parent_types=["aws-alb", "aws-nlb"],
),
# -- Compute --
_aws_type(
"aws-ec2-instance",
"An AWS EC2 virtual machine instance.",
inherits="cloud-compute-instance",
parent_types=["aws-subnet", "aws-region"],
),
_aws_type(
"aws-ami",
"An AWS Amazon Machine Image.",
parent_types=["aws-region"],
),
_aws_type(
"aws-launch-template",
"An AWS EC2 launch template.",
parent_types=["aws-region"],
),
_aws_type(
"aws-asg",
"An AWS Auto Scaling Group.",
parent_types=["aws-region"],
),
# -- Storage --
_aws_type(
"aws-s3-bucket",
"An AWS S3 object storage bucket.",
inherits="cloud-object-store",
parent_types=["aws-region"],
),
_aws_type(
"aws-ebs-volume",
"An AWS Elastic Block Store volume.",
inherits="cloud-block-storage",
parent_types=["aws-region"],
),
_aws_type(
"aws-efs-filesystem",
"An AWS Elastic File System.",
parent_types=["aws-region"],
),
# -- IAM --
_aws_type(
"aws-iam-user",
"An AWS IAM user.",
inherits="cloud-identity-principal",
parent_types=["aws-account"],
),
_aws_type(
"aws-iam-role",
"An AWS IAM role.",
inherits="cloud-role",
parent_types=["aws-account"],
),
_aws_type(
"aws-iam-policy",
"An AWS IAM managed policy.",
inherits="cloud-policy",
parent_types=["aws-account"],
),
_aws_type(
"aws-iam-instance-profile",
"An AWS IAM instance profile (role wrapper for EC2).",
parent_types=["aws-account"],
),
# -- Observability --
_aws_type(
"aws-cloudwatch-log-group",
"An AWS CloudWatch Logs log group.",
inherits="cloud-log-group",
parent_types=["aws-region"],
),
_aws_type(
"aws-cloudwatch-alarm",
"An AWS CloudWatch alarm.",
inherits="cloud-alarm",
parent_types=["aws-region"],
),
_aws_type(
"aws-cloudwatch-metric",
"An AWS CloudWatch custom or service metric.",
parent_types=["aws-region"],
),
_aws_type(
"aws-eventbridge-bus",
"An AWS EventBridge event bus.",
parent_types=["aws-region"],
child_types=["aws-eventbridge-rule"],
),
_aws_type(
"aws-eventbridge-rule",
"An AWS EventBridge rule.",
parent_types=["aws-eventbridge-bus"],
child_types=["aws-eventbridge-target"],
),
_aws_type(
"aws-eventbridge-target",
"An AWS EventBridge rule target.",
parent_types=["aws-eventbridge-rule"],
),
# -- Messaging --
_aws_type(
"aws-sqs-queue",
"An AWS SQS message queue.",
inherits="cloud-queue",
parent_types=["aws-region"],
),
_aws_type(
"aws-sns-topic",
"An AWS SNS notification topic.",
inherits="cloud-topic",
parent_types=["aws-region"],
child_types=["aws-sns-subscription"],
),
_aws_type(
"aws-sns-subscription",
"An AWS SNS topic subscription.",
parent_types=["aws-sns-topic"],
),
# -- Containers --
_aws_type(
"aws-ecr-repo",
"An AWS Elastic Container Registry repository.",
inherits="cloud-container-repo",
parent_types=["aws-region"],
),
_aws_type(
"aws-ecs-cluster",
"An AWS Elastic Container Service cluster.",
inherits="cloud-container-cluster",
parent_types=["aws-region"],
child_types=["aws-ecs-service"],
),
_aws_type(
"aws-ecs-service",
"An AWS ECS service (running task instances).",
inherits="cloud-container-service",
parent_types=["aws-ecs-cluster"],
),
_aws_type(
"aws-ecs-task-def",
"An AWS ECS task definition.",
parent_types=["aws-region"],
),
_aws_type(
"aws-eks-cluster",
"An AWS Elastic Kubernetes Service cluster.",
inherits="cloud-container-cluster",
parent_types=["aws-region"],
child_types=["aws-eks-nodegroup"],
),
_aws_type(
"aws-eks-nodegroup",
"An AWS EKS managed node group.",
parent_types=["aws-eks-cluster"],
),
]
# ===== GCP / Azure placeholders =====
#
# Flat provider-level types kept for forward compatibility. Full
# hierarchies for these providers are deferred to future PRs.
_GCP_AZURE_TYPES: list[dict[str, Any]] = [
{
"name": "gcp",
"description": "Google Cloud Platform resource (hierarchy pending).",
"resource_kind": "physical",
"sandbox_strategy": "none",
"user_addable": True,
"built_in": True,
"inherits": "cloud-account",
"cli_args": [
{
"name": "service-account-json-path",
"type": "string",
"required": False,
"description": "Path to GCP service account JSON key file.",
},
{
"name": "project-id",
"type": "string",
"required": False,
"description": "GCP project ID.",
},
{
"name": "region",
"type": "string",
"required": False,
"description": "GCP region (e.g. us-central1).",
},
],
"parent_types": [],
"child_types": [],
"handler": _CLOUD_HANDLER,
"capabilities": dict(_CLOUD_CAPS_RW),
},
{
"name": "azure",
"description": "Microsoft Azure cloud resource (hierarchy pending).",
"resource_kind": "physical",
"sandbox_strategy": "none",
"user_addable": True,
"built_in": True,
"inherits": "cloud-account",
"cli_args": [
{
"name": "subscription-id",
"type": "string",
"required": False,
"description": "Azure subscription ID.",
},
{
"name": "tenant-id",
"type": "string",
"required": False,
"description": "Azure Active Directory tenant ID.",
},
{
"name": "client-id",
"type": "string",
"required": False,
"description": "Azure service principal client ID.",
},
{
"name": "client-secret",
"type": "string",
"required": False,
"description": "Azure service principal client secret.",
},
{
"name": "region",
"type": "string",
"required": False,
"description": "Azure region (e.g. eastus).",
},
],
"parent_types": [],
"child_types": [],
"handler": _CLOUD_HANDLER,
"capabilities": dict(_CLOUD_CAPS_RW),
},
]
# ===== Assemble final list =====
BUILTIN_TYPES: list[dict[str, Any]] = [
*_GIT_FS_CONTAINER_TYPES,
*_CLOUD_BASE_TYPES,
*_AWS_TYPES,
*_GCP_AZURE_TYPES,
*DATABASE_TYPE_DEFS,
]
# ---------------------------------------------------------------------------
# DB ↔ domain conversion helpers
# ---------------------------------------------------------------------------
@@ -155,6 +155,7 @@ class ResourceTypeSpec(BaseModel):
# Built-in resource type names (unnamespaced)
BUILTIN_NAMES: ClassVar[frozenset[str]] = frozenset(
{
# -- Git / Filesystem / Container --
"git-checkout",
"fs-directory",
"fs-mount",
@@ -166,6 +167,69 @@ class ResourceTypeSpec(BaseModel):
"mysql",
"sqlite",
"duckdb",
# -- Generic cloud base types --
"cloud-account",
"cloud-region",
"cloud-network",
"cloud-subnet",
"cloud-security-group",
"cloud-load-balancer",
"cloud-compute-instance",
"cloud-object-store",
"cloud-block-storage",
"cloud-identity-principal",
"cloud-role",
"cloud-policy",
"cloud-log-group",
"cloud-alarm",
"cloud-queue",
"cloud-topic",
"cloud-container-repo",
"cloud-container-cluster",
"cloud-container-service",
# -- AWS provider types --
"aws-account",
"aws-region",
"aws-vpc",
"aws-subnet",
"aws-igw",
"aws-nat-gw",
"aws-route-table",
"aws-nacl",
"aws-security-group",
"aws-alb",
"aws-nlb",
"aws-target-group",
"aws-listener",
"aws-ec2-instance",
"aws-ami",
"aws-launch-template",
"aws-asg",
"aws-s3-bucket",
"aws-ebs-volume",
"aws-efs-filesystem",
"aws-iam-user",
"aws-iam-role",
"aws-iam-policy",
"aws-iam-instance-profile",
"aws-cloudwatch-log-group",
"aws-cloudwatch-alarm",
"aws-cloudwatch-metric",
"aws-eventbridge-bus",
"aws-eventbridge-rule",
"aws-eventbridge-target",
"aws-sqs-queue",
"aws-sns-topic",
"aws-sns-subscription",
"aws-ecr-repo",
"aws-ecs-cluster",
"aws-ecs-service",
"aws-ecs-task-def",
"aws-eks-cluster",
"aws-eks-nodegroup",
# -- GCP / Azure (flat, hierarchy pending) --
"gcp",
"azure",
}
)
@@ -14,11 +14,13 @@ which defines a single ``resolve`` method returning a
## Built-in Handlers
| Handler | Resource Type | Sandbox Strategy |
|--------------------------|--------------------------|------------------|
| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` |
| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write``|
| ``DevcontainerHandler`` | ``devcontainer-instance``| ``snapshot`` |
| Handler | Resource Type(s) | Strategy |
|--------------------------|---------------------------|----------------------|
| ``GitCheckoutHandler`` | ``git-checkout`` | ``git_worktree`` |
| ``FsDirectoryHandler`` | ``fs-directory`` | ``copy_on_write`` |
| ``DevcontainerHandler`` | ``devcontainer-instance`` | ``snapshot`` |
| ``CloudResourceHandler`` | ``cloud-*``, ``aws-*`` | ``none`` |
| ``DatabaseHandler`` | ``postgres``, ``mysql`` | ``transaction`` |
## Handler Resolution
@@ -27,6 +29,7 @@ Handler strings stored on :class:`ResourceTypeSpec` use the format
dynamically imports the module and returns an instance.
"""
from cleveragents.resource.handlers.cloud import CloudResourceHandler
from cleveragents.resource.handlers.database import DatabaseResourceHandler
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
@@ -38,6 +41,7 @@ from cleveragents.resource.handlers.resolver import (
)
__all__ = [
"CloudResourceHandler",
"DatabaseResourceHandler",
"DevcontainerHandler",
"FsDirectoryHandler",
+530
View File
@@ -0,0 +1,530 @@
"""Cloud infrastructure resource handler for CleverAgents.
Provides the :class:`CloudResourceHandler` for cloud infrastructure
resource types. The handler supports a hierarchical type model where
provider-specific types (``aws-account``, ``aws-vpc``, etc.) inherit
from generic ``cloud-*`` base types.
This handler validates configuration and resolves credentials from
environment variables and profile names but does **not** execute any
cloud SDK operations -- actual execution raises
:exc:`NotImplementedError`.
Cloud resource types are registered as built-in types at bootstrap and
use ``sandbox_strategy = "none"`` because cloud sandbox isolation is
not yet implemented.
## Provider Detection
The handler extracts the cloud provider from the resource type name:
- ``aws-*`` or ``aws`` AWS provider
- ``gcp-*`` or ``gcp`` GCP provider
- ``azure-*`` or ``azure`` Azure provider
- ``cloud-*`` generic (no provider-specific validation)
## Credential Resolution
Credentials are resolved in this priority order:
1. Explicit configuration values (passed via ``properties``)
2. Environment variables
3. Profile names (AWS only -- ``AWS_PROFILE``)
No cloud SDK dependencies are required. Credential values are never
logged; the existing :mod:`cleveragents.shared.redaction` patterns
handle masking.
Based on:
- Issue #343: Cloud Infrastructure Resources
- implementation_plan.md group M7.post-resource-cloud
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Any
from cleveragents.domain.models.core.resource import Resource
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.shared.redaction import REDACTED, is_sensitive_key
from cleveragents.tool.context import BoundResource
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Cloud credential field definitions
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _CredentialField:
"""Metadata for a single credential or configuration field."""
name: str
env_var: str
required: bool = True
sensitive: bool = False
description: str = ""
@dataclass(frozen=True)
class CloudProviderSpec:
"""Specification of a cloud provider's credential and config fields."""
provider: str
description: str
credential_fields: tuple[_CredentialField, ...]
metadata_fields: tuple[_CredentialField, ...] = ()
profile_env_var: str | None = None
required_fields: frozenset[str] = field(default_factory=frozenset)
@property
def all_fields(self) -> tuple[_CredentialField, ...]:
"""Return all fields (credential + metadata)."""
return self.credential_fields + self.metadata_fields
# ---------------------------------------------------------------------------
# Provider specifications
# ---------------------------------------------------------------------------
AWS_SPEC = CloudProviderSpec(
provider="aws",
description="Amazon Web Services cloud resource.",
credential_fields=(
_CredentialField(
name="access-key-id",
env_var="AWS_ACCESS_KEY_ID",
required=True,
sensitive=True,
description="AWS access key ID.",
),
_CredentialField(
name="secret-access-key",
env_var="AWS_SECRET_ACCESS_KEY",
required=True,
sensitive=True,
description="AWS secret access key.",
),
_CredentialField(
name="session-token",
env_var="AWS_SESSION_TOKEN",
required=False,
sensitive=True,
description="AWS session token (optional, for temporary credentials).",
),
),
metadata_fields=(
_CredentialField(
name="region",
env_var="AWS_REGION",
required=False,
sensitive=False,
description="AWS region (e.g. us-east-1).",
),
_CredentialField(
name="profile",
env_var="AWS_PROFILE",
required=False,
sensitive=False,
description="AWS profile name from ~/.aws/credentials.",
),
),
profile_env_var="AWS_PROFILE",
required_fields=frozenset({"access-key-id", "secret-access-key"}),
)
GCP_SPEC = CloudProviderSpec(
provider="gcp",
description="Google Cloud Platform resource.",
credential_fields=(
_CredentialField(
name="service-account-json-path",
env_var="GOOGLE_APPLICATION_CREDENTIALS",
required=True,
sensitive=True,
description="Path to GCP service account JSON key file.",
),
),
metadata_fields=(
_CredentialField(
name="project-id",
env_var="GCLOUD_PROJECT",
required=True,
sensitive=False,
description="GCP project ID.",
),
_CredentialField(
name="region",
env_var="GCP_REGION",
required=False,
sensitive=False,
description="GCP region (e.g. us-central1).",
),
),
required_fields=frozenset({"service-account-json-path", "project-id"}),
)
AZURE_SPEC = CloudProviderSpec(
provider="azure",
description="Microsoft Azure cloud resource.",
credential_fields=(
_CredentialField(
name="subscription-id",
env_var="AZURE_SUBSCRIPTION_ID",
required=True,
sensitive=True,
description="Azure subscription ID.",
),
_CredentialField(
name="tenant-id",
env_var="AZURE_TENANT_ID",
required=True,
sensitive=True,
description="Azure Active Directory tenant ID.",
),
_CredentialField(
name="client-id",
env_var="AZURE_CLIENT_ID",
required=True,
sensitive=True,
description="Azure service principal client ID.",
),
_CredentialField(
name="client-secret",
env_var="AZURE_CLIENT_SECRET",
required=True,
sensitive=True,
description="Azure service principal client secret.",
),
),
metadata_fields=(
_CredentialField(
name="region",
env_var="AZURE_REGION",
required=False,
sensitive=False,
description="Azure region (e.g. eastus).",
),
),
required_fields=frozenset(
{"subscription-id", "tenant-id", "client-id", "client-secret"}
),
)
#: Mapping from provider name to its specification.
CLOUD_PROVIDERS: dict[str, CloudProviderSpec] = {
"aws": AWS_SPEC,
"gcp": GCP_SPEC,
"azure": AZURE_SPEC,
}
#: Provider prefixes for hierarchical type name detection.
_PROVIDER_PREFIXES: tuple[str, ...] = ("aws-", "gcp-", "azure-")
def extract_provider(type_name: str) -> str | None:
"""Extract the cloud provider key from a resource type name.
Supports both flat names (``aws``, ``gcp``, ``azure``) and
hierarchical names (``aws-account``, ``aws-vpc``, etc.).
Returns ``None`` for generic ``cloud-*`` base types.
Args:
type_name: Resource type name.
Returns:
Provider key (``aws``, ``gcp``, ``azure``) or ``None``.
"""
# Direct match (flat provider names)
if type_name in CLOUD_PROVIDERS:
return type_name
# Hierarchical names: aws-*, gcp-*, azure-*
for prefix in _PROVIDER_PREFIXES:
if type_name.startswith(prefix):
return prefix.rstrip("-")
# Generic cloud-* base types have no specific provider
return None
# ---------------------------------------------------------------------------
# Credential resolution
# ---------------------------------------------------------------------------
def _safe_value_repr(field_def: _CredentialField, value: str | None) -> str:
"""Return a safe representation of a credential value for logging."""
if value is None:
return "<not set>"
if field_def.sensitive or is_sensitive_key(field_def.name):
return REDACTED
return value
def resolve_credentials(
provider: str,
properties: dict[str, Any],
) -> dict[str, str | None]:
"""Resolve credentials for a cloud provider from properties and env vars.
Resolution priority:
1. Explicit values in ``properties``
2. Environment variables
Args:
provider: Cloud provider name (``aws``, ``gcp``, ``azure``).
properties: Resource properties dict with explicit values.
Returns:
Dict mapping field names to resolved values (``None`` if unresolved).
Raises:
ValueError: If the provider name is not recognised.
"""
spec = CLOUD_PROVIDERS.get(provider)
if spec is None:
raise ValueError(
f"Unknown cloud provider '{provider}'. "
f"Supported providers: {', '.join(sorted(CLOUD_PROVIDERS))}."
)
resolved: dict[str, str | None] = {}
for field_def in spec.all_fields:
# Priority 1: explicit property
explicit = properties.get(field_def.name)
if explicit is not None and str(explicit).strip():
resolved[field_def.name] = str(explicit)
continue
# Priority 2: environment variable
env_val = os.environ.get(field_def.env_var)
if env_val is not None and env_val.strip():
resolved[field_def.name] = env_val
continue
resolved[field_def.name] = None
return resolved
def validate_credentials(
provider: str,
resolved: dict[str, str | None],
) -> list[str]:
"""Validate that all required credentials are present.
Args:
provider: Cloud provider name.
resolved: Dict from :func:`resolve_credentials`.
Returns:
List of error messages (empty if valid).
"""
spec = CLOUD_PROVIDERS.get(provider)
if spec is None:
return [
f"Unknown cloud provider '{provider}'. "
f"Supported providers: {', '.join(sorted(CLOUD_PROVIDERS))}."
]
# Check if a profile is set (AWS only) -- profile can satisfy
# required credential fields
has_profile = False
if spec.profile_env_var is not None:
profile_val = resolved.get("profile")
if profile_val is not None and profile_val.strip():
has_profile = True
errors: list[str] = []
for field_def in spec.all_fields:
if not field_def.required:
continue
value = resolved.get(field_def.name)
# If a profile is configured, skip credential-field checks
# (the SDK will resolve them from the profile).
if has_profile and field_def in spec.credential_fields:
continue
if value is None:
safe_repr = _safe_value_repr(field_def, value)
errors.append(
f"Required field '{field_def.name}' for provider "
f"'{provider}' is missing (current value: {safe_repr}). "
f"Set it via resource properties or the "
f"{field_def.env_var} environment variable."
)
return errors
# ---------------------------------------------------------------------------
# CloudResourceHandler
# ---------------------------------------------------------------------------
class CloudResourceHandler:
"""Handler for cloud infrastructure resource types.
Validates cloud resource configuration and resolves credentials
from environment variables and profile names. Actual cloud
operations are **not** implemented -- calling :meth:`resolve`
raises :exc:`NotImplementedError` after validation.
Supports hierarchical type names (``aws-account``, ``aws-vpc``,
etc.) in addition to flat provider names (``aws``, ``gcp``,
``azure``). Generic ``cloud-*`` base types skip provider-specific
credential validation.
This handler satisfies the
:class:`~cleveragents.resource.handlers.protocol.ResourceHandler`
protocol.
"""
def resolve(
self,
*,
resource: Resource,
plan_id: str,
slot_name: str,
sandbox_manager: SandboxManager,
access: str = "read_only",
) -> BoundResource:
"""Validate cloud resource configuration and raise.
Performs full credential validation and resolution, then
raises :exc:`NotImplementedError` because cloud sandbox
execution is not yet implemented.
Args:
resource: A cloud resource instance.
plan_id: The plan requesting the sandbox.
slot_name: Name of the tool resource slot being filled.
sandbox_manager: The sandbox lifecycle manager (unused).
access: Access mode (unused).
Raises:
ValueError: If required credentials are missing or the
resource type is not a supported cloud provider.
NotImplementedError: Always, after successful validation.
"""
type_name = resource.resource_type_name
provider = extract_provider(type_name)
if provider is None:
# Generic cloud-* base type — no provider-specific validation.
raise NotImplementedError(
f"Cloud resource execution for generic type '{type_name}' "
f"is not yet implemented. Resource '{resource.resource_id}' "
f"(plan={plan_id}, slot={slot_name}). "
f"Generic cloud base types cannot be resolved directly."
)
if provider not in CLOUD_PROVIDERS:
raise ValueError(
f"Resource type '{type_name}' maps to unknown provider "
f"'{provider}'. "
f"Supported: {', '.join(sorted(CLOUD_PROVIDERS))}."
)
# Resolve credentials
resolved = resolve_credentials(provider, dict(resource.properties))
# Log resolution (with redaction)
for field_def in CLOUD_PROVIDERS[provider].all_fields:
safe = _safe_value_repr(field_def, resolved.get(field_def.name))
logger.debug(
"Cloud credential %s/%s = %s",
provider,
field_def.name,
safe,
)
# Validate credentials (only for account-level types that carry creds)
is_account_type = type_name in (
"aws",
"gcp",
"azure",
"aws-account",
)
if is_account_type:
errors = validate_credentials(provider, resolved)
if errors:
raise ValueError(
f"Cloud resource validation failed for '{type_name}' "
f"(provider={provider}): " + "; ".join(errors)
)
raise NotImplementedError(
f"Cloud resource execution for '{type_name}' "
f"(provider={provider}) is not yet implemented. "
f"Resource '{resource.resource_id}' "
f"(plan={plan_id}, slot={slot_name}) validated successfully "
f"but sandbox provisioning for cloud resources is pending."
)
# ---------------------------------------------------------------------------
# Stubbed sandbox strategies
# ---------------------------------------------------------------------------
class CloudSandboxStrategy:
"""Stubbed sandbox strategy for cloud resources.
Validates configuration but raises :exc:`NotImplementedError` for
all lifecycle operations (create, commit, rollback). This is the
expected behaviour per the issue specification.
"""
def __init__(self, provider: str) -> None:
self._provider = provider
def validate(self, properties: dict[str, Any]) -> list[str]:
"""Validate cloud resource configuration.
Args:
properties: Resource properties dict.
Returns:
List of validation error messages (empty if valid).
"""
resolved = resolve_credentials(self._provider, properties)
return validate_credentials(self._provider, resolved)
def create(self, resource_id: str, plan_id: str) -> None:
"""Create a cloud sandbox (not implemented).
Raises:
NotImplementedError: Always.
"""
raise NotImplementedError(
f"Cloud sandbox creation for provider '{self._provider}' "
f"is not yet implemented (resource={resource_id}, plan={plan_id})."
)
def commit(self, resource_id: str, plan_id: str) -> None:
"""Commit a cloud sandbox (not implemented).
Raises:
NotImplementedError: Always.
"""
raise NotImplementedError(
f"Cloud sandbox commit for provider '{self._provider}' "
f"is not yet implemented (resource={resource_id}, plan={plan_id})."
)
def rollback(self, resource_id: str, plan_id: str) -> None:
"""Rollback a cloud sandbox (not implemented).
Raises:
NotImplementedError: Always.
"""
raise NotImplementedError(
f"Cloud sandbox rollback for provider '{self._provider}' "
f"is not yet implemented (resource={resource_id}, plan={plan_id})."
)