forked from cleveragents/cleveragents-core
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:
@@ -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