diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4c68a172d..0a8bbaf76 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -73,6 +73,7 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. * HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase. +* HAL 9000 has contributed cloud infrastructure resource type stubs (PR #10592 / issue #8607): implemented the `CloudResource` base Pydantic model with provider-specific subclasses (`AWSResource`, `GCPResource`, `AzureResource`) including field validation, auto-provider assignment, and lower-casing of provider IDs. The feature is part of Epic #8568 (Resource Types & Container Tool Execution). Included BDD Behave tests covering all instantiation and validation scenarios. * HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata. * HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568). * HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback []` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality. diff --git a/features/resource/cloud_types.feature b/features/resource/cloud_types.feature new file mode 100644 index 000000000..4f25f33f2 --- /dev/null +++ b/features/resource/cloud_types.feature @@ -0,0 +1,53 @@ +Feature: Cloud resource type stubs + + Cloud resource type stubs provide base models for AWS, GCP, and Azure + infrastructure resources. They validate provider names, regions, and + provider-specific fields without executing any cloud SDK operations. + + Background: + Given the cloud resource stub module is available + + Scenario: CloudResource accepts a provider name and validates it + Given a new CloudResource is being created + When a valid provider "aws" is set + Then the resource should have provider "aws" + + Scenario: CloudResource rejects an unknown provider + Given a new CloudResource is being created + When an invalid provider "oracle" is set + Then it should reject an invalid provider + + Scenario: CloudResource accepts and stores resource_type field + Given a new CloudResource with type "ec2_instance" + When the resource_type is accepted as expected + Then the resource_type should be "ec2_instance" + + Scenario: AWSResource auto-sets provider to "aws" + Given a new AWSResource is being created + When provider is auto-set with resource_id "aws-res-1" + Then the resource should have provider "aws" + + Scenario: GCPResource auto-sets provider to "gcp" + Given a new GCPResource is being created + When provider is auto-set with project_id "my-project" + Then the resource should have provider "gcp" + + Scenario: AzureResource auto-sets provider to "azure" + Given a new AzureResource is being created + When provider is auto-set with subscription_id "sub-123" + Then the resource should have provider "azure" + + Scenario: AWSResource validates tags + Given a new AWSResource with tags + When a tag has an empty key + Then it should reject tags with empty key + + Scenario: GCPResource validates project_id + Given a new GCPResource is being created + When project_id is set to "My-Project" + Then the project_id should be lowercased + + Scenario: AzureResource validates subscription_id + Given a new AzureResource is being created + When subscription_id is set to "SUB-123" + Then the subscription_id should be lowercased diff --git a/features/steps/cloud_types_steps.py b/features/steps/cloud_types_steps.py new file mode 100644 index 000000000..93cd446f1 --- /dev/null +++ b/features/steps/cloud_types_steps.py @@ -0,0 +1,260 @@ +"""Behave step definitions for cloud resource type stubs.""" + +from __future__ import annotations + +from pydantic import ValidationError + +from behave import given, then, when + +from cleveragents.resource.cloud_types import ( + AWSResource, + AzureResource, + CloudResource, + GCPResource, +) + + +@given("the cloud resource stub module is available") +def step_module_available(context): + """Cloud resource stub module is importable.""" + assert CloudResource is not None + assert AWSResource is not None + assert GCPResource is not None + assert AzureResource is not None + + +# ----------------------------- CloudResource ----------------------------- + + +@given("a new CloudResource is being created") +def step_new_cloud_resource(context): + """Store an incomplete CloudResource for construction.""" + context.pending_resource = { + "resource_id": "cloud-test-001", + "region": "us-east-1", + } + + +@when('a valid provider "aws" is set') +def step_set_aws_provider(context): + """Set valid provider on pending resource and try instantiation.""" + data = dict(context.pending_resource) + data["provider"] = "aws" + try: + context.exc = None + context.result = CloudResource(**data) + except ValidationError as exc: + context.exc = exc + + +@when('an invalid provider "oracle" is set') +def step_set_invalid_provider(context): + """Set invalid provider and expect a validation error.""" + data = dict(context.pending_resource) + data["provider"] = "oracle" + try: + context.exc = None + context.result = CloudResource(**data) + except ValidationError as exc: + context.exc = exc + + +@then('the resource should have provider "aws"') +def step_resource_provider_is_aws(context): + """Assert the constructed resource has provider 'aws'.""" + assert context.exc is None + assert context.result.provider == "aws" + + +@then("it should reject an invalid provider") +def step_rejects_invalid_provider(context): + """Assert a ValidationError was raised for an invalid provider.""" + assert context.exc is not None + assert isinstance(context.exc, ValidationError) + + +@given('a new CloudResource with type "{resource_type}"') +def step_new_cloud_resource_with_type(context, resource_type: str): + """Store a CloudResource with specified resource_type for construction.""" + context.pending_resource = { + "resource_id": "cloud-test-001", + "type": resource_type, + } + + +@when("the resource_type is accepted as expected") +def step_cloud_resource_type_accepted(context): + """Create the CloudResource with pending data including provider.""" + data = dict(context.pending_resource) + data["provider"] = "aws" + try: + context.exc = None + context.result = CloudResource(**data) + except ValidationError as exc: + context.exc = exc + + +@then('the resource_type should be "{expected}"') +def step_resource_type_is(context, expected: str): + """Assert the constructed resource has the expected resource_type.""" + assert context.exc is None + assert context.result.type == expected + + +@given("a new AWSResource is being created") +def step_new_aws_resource(context): + """Store an incomplete AWSResource for construction.""" + context.pending_resource = { + "resource_id": "aws-res-test-001", + "region": "us-west-2", + } + + +@when('provider is auto-set with resource_id "aws-res-1"') +def step_aws_auto_provider(context): + """Create AWSResource with auto-set provider.""" + data = dict(context.pending_resource) + data["resource_id"] = "aws-res-1" + try: + context.exc = None + context.result = AWSResource(**data) + except ValidationError as exc: + context.exc = exc + + +@given("a new AWSResource with tags") +def step_new_aws_resource_with_tags(context): + """Store an AWSResource with tags for construction.""" + context.tags_resource = { + "resource_id": "aws-res-tags-001", + "region": "us-east-1", + } + + +@when("a tag has an empty key") +def step_aws_empty_tag_key(context): + """Set tag with empty key and expect ValueError.""" + data = { + "resource_id": "aws-res-tags-001", + "region": "us-east-1", + "tags": {"": "value"}, + } + try: + context.exc = None + context.result = AWSResource(**data) + except ValidationError as exc: + context.exc = exc + + +@when('its region is set to "us-west-2"') +def step_aws_region(context): + """Set AWS resource region.""" + context.pending_resource["region"] = "us-west-2" + + +# ----------------------------- GCPResource ------------------------------- + + +@given("a new GCPResource is being created") +def step_new_gcp_resource(context): + """Store an incomplete GCPResource for construction.""" + context.pending_resource = { + "project_id": "my-project", + "region": "us-central-1", + } + + +@when('provider is auto-set with project_id "my-project"') +def step_gcp_auto_provider(context): + """Create GCPResource with auto-set provider.""" + data = dict(context.pending_resource) + data["project_id"] = "my-project" + try: + context.exc = None + context.result = GCPResource(**data) + except ValidationError as exc: + context.exc = exc + + +@when('project_id is set to "My-Project"') +def step_gcp_project_id_lowercase(context): + """Set project_id with mixed case and expect lowercased result.""" + data = { + "project_id": "My-Project", + "region": "us-central-1", + } + try: + context.exc = None + context.result = GCPResource(**data) + except ValidationError as exc: + context.exc = exc + + +@then("the project_id should be lowercased") +def step_gcp_project_id_is_lowercase(context): + """Assert project_id was lowercased.""" + assert context.exc is None + assert context.result.project_id == "my-project" + + +# ----------------------------- AzureResource ----------------------------- + + +@given("a new AzureResource is being created") +def step_new_azure_resource(context): + """Store an incomplete AzureResource for construction.""" + context.pending_resource = { + "subscription_id": "sub-123", + "tenant_id": "tenant-456", + } + + +@when('provider is auto-set with subscription_id "sub-123"') +def step_azure_auto_provider(context): + """Create AzureResource with auto-set provider.""" + data = dict(context.pending_resource) + data["subscription_id"] = "sub-123" + data["tenant_id"] = "tenant-456" + try: + context.exc = None + context.result = AzureResource(**data) + except ValidationError as exc: + context.exc = exc + + +@when('subscription_id is set to "SUB-123"') +def step_azure_subscription_id_lowercase(context): + """Set subscription_id with mixed case and expect lowercased.""" + data = { + "subscription_id": "SUB-123", + "tenant_id": "TENTANT-456", + } + try: + context.exc = None + context.result = AzureResource(**data) + except ValidationError as exc: + context.exc = exc + + +@then('the resource should have provider "azure"') +def step_azure_provider_is_azure(context): + """Assert AzureResource has provider 'azure'.""" + assert context.exc is None + assert context.result.provider == "azure" + + +@then("the subscription_id should be lowercased") +def step_azure_subscription_is_lowercase(context): + """Assert subscription_id was lowercased.""" + assert context.exc is None + assert context.result.subscription_id == "sub-123" + + +# ------------------------ GCPResource provider assertion -------------------- + + +@then('the resource should have provider "gcp"') +def step_gcp_provider_is_gcp(context): + """Assert GCPResource has provider 'gcp'.""" + assert context.exc is None + assert context.result.provider == "gcp" diff --git a/src/cleveragents/resource/__init__.py b/src/cleveragents/resource/__init__.py index db1742ef4..ed37feafd 100644 --- a/src/cleveragents/resource/__init__.py +++ b/src/cleveragents/resource/__init__.py @@ -1,5 +1,17 @@ -"""Resource type schema loading, validation, and inheritance resolution.""" +"""Resource type schema loading, validation, and inheritance resolution. +Cloud resource type stubs (``CloudResource``, ``AWSResource``, +``GCPResource``, ``AzureResource``) are also exported from this +package for convenient access. +""" + +from cleveragents.resource.cloud_types import ( + CLOUD_PROVIDERS, + AWSResource, + AzureResource, + CloudResource, + GCPResource, +) from cleveragents.resource.inheritance import ( MAX_CHAIN_DEPTH, ResourceTypeCircularInheritanceError, @@ -20,8 +32,13 @@ from cleveragents.resource.virtual import ( ) __all__ = [ + "CLOUD_PROVIDERS", "MAX_CHAIN_DEPTH", "APIEndpointResource", + "AWSResource", + "AzureResource", + "CloudResource", + "GCPResource", "MetricResource", "ResourceTypeCircularInheritanceError", "ResourceTypeInheritanceDepthError", diff --git a/src/cleveragents/resource/cloud_types.py b/src/cleveragents/resource/cloud_types.py new file mode 100644 index 000000000..f6f096be9 --- /dev/null +++ b/src/cleveragents/resource/cloud_types.py @@ -0,0 +1,413 @@ +"""Cloud infrastructure resource type stubs for CleverAgents. + +Provides the base :class:`CloudResource` and provider-specific stubs +:class:`AWSResource`, :class:`GCPResource`, and :class:`AzureResource`. + +These stubs define the domain shapes for cloud infrastructure resources +used by the ``CloudResourceHandler``. They do **not** execute any +cloud SDK operations -- actual provisioning raises +:exc:`NotImplementedError`. + +## Provider-specific stubs + +| Class | Provider | Key properties | +|-----------------|--------------|--------------------------------------------| +| ``CloudResource`` | generic | provider, type, region, resrc id, state | +| ``AWSResource`` | AWS | arn, tags, properties | +| ``GCPResource`` | GCP | project_id, properties | +| ``AzureResource`` | Azure | sub_id, tenant_id, props | + +Based on: + - Issue #343: Cloud Infrastructure Resources + - Handler: cleveragents.resource.handlers.cloud +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# --------------------------------------------------------------------------- +# Module-level constants (must precede classes that reference them in validators) +# --------------------------------------------------------------------------- + +#: Set of recognised cloud providers. +CLOUD_PROVIDERS: frozenset[str] = frozenset({"aws", "gcp", "azure"}) + + +class CloudResource(BaseModel): + """Base stub for all cloud infrastructure resources. + + Provides common properties shared across all cloud providers + and serves as the parent class for concrete provider stubs. + + **Constraints**: + + - ``provider`` must match a known cloud provider (aws, gcp, azure) + - ``region`` must be a non-empty string + + **Convenience methods**: + + - ``is_aws()`` -- Check if the provider is AWS + - ``is_gcp()`` -- Check if the provider is GCP + - ``is_azure()`` -- Check if the provider is Azure + """ + + model_config = ConfigDict(frozen=True, str_strip_whitespace=True) + + resource_id: str | None = Field( + default=None, + description="Unique identifier for the cloud resource (e.g., ARN, ID).", + ) + provider: str = Field( + ..., + description="Cloud provider identifier (aws, gcp, azure).", + ) + type: str | None = Field( + default=None, + description=( + "Type of cloud resource (e.g., s3_bucket, ec2_instance, gcs_bucket)." + ), + ) + region: str | None = Field( + default=None, + description="Cloud provider region (e.g., us-east-1, us-central1, eastus).", + ) + account_id: str | None = Field( + default=None, + description="Root account/subscription owner ID.", + ) + state: str | None = Field( + default=None, + description="Current lifecycle state of the resource (e.g., running, pending).", + ) + + @field_validator("provider") + @classmethod + def validate_provider(cls, v: str) -> str: + """Validate the provider is a known cloud provider. + + Args: + v: Provider name to validate. + + Returns: + Lowercased provider name. + + Raises: + ValueError: If the provider is not recognised. + """ + v_lower = v.lower().strip() + if v_lower not in CLOUD_PROVIDERS: + raise ValueError( + f"Unknown cloud provider '{v}'. " + f"Supported providers: {', '.join(CLOUD_PROVIDERS)}." + ) + return v_lower + + @field_validator("state") + @classmethod + def validate_state(cls, v: str | None) -> str | None: + """Validate state is non-empty if provided. + + Args: + v: Resource state to validate. + + Returns: + Lowercased state value or None. + """ + if v is not None and not v.strip(): + raise ValueError("Resource state cannot be empty (use None instead)") + return v.lower() if v else None + + # -- Type-check convenience methods -------------------------------------- + + def is_aws(self) -> bool: + """Check if this resource uses the AWS provider.""" + return self.provider == "aws" + + def is_gcp(self) -> bool: + """Check if this resource uses the GCP provider.""" + return self.provider == "gcp" + + def is_azure(self) -> bool: + """Check if this resource uses the Azure provider.""" + return self.provider == "azure" + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"provider={self.provider!r}, " + f"type={self.type!r}, " + f"resource_id={self.resource_id!r}, " + f"region={self.region!r})" + ) + + +class AWSResource(CloudResource): + """Stub for an AWS (Amazon Web Services) infrastructure resource. + + Provider-specific properties are resolved at runtime from + environment variables or explicit ``properties`` on the + :class:`~cleveragents.domain.models.core.resource.Resource` + domain object. This stub stores them as opaque + :attr:`properties` to avoid credential exposure. + + **Constraints**: + + - Inherited from ``CloudResource``: ``provider`` must be ``"aws"`` + - Credentials should be resolved via the handler, not stored here + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + properties: dict[str, Any] = Field( + default_factory=dict, + description=( + "AWS-specific properties resolved from environment variables " + "or resource configuration. Never set credentials directly " + "on this model." + ), + ) + arn: str | None = Field( + default=None, + description="AWS ARN (Amazon Resource Name) for the resource.", + ) + tags: dict[str, str] = Field( + default_factory=dict, + description="AWS resource tags (key-value pairs).", + ) + + @field_validator("arn") + @classmethod + def validate_arn(cls, v: str | None) -> str | None: + """Validate ARN format if provided. + + Args: + v: ARN string to validate. + + Returns: + ARN value or None unmodified. + """ + if v is not None and not v.strip(): + raise ValueError("ARN cannot be empty (use None instead)") + return v + + @field_validator("tags") + @classmethod + def validate_tags(cls, v: dict[str, str]) -> dict[str, str]: + """Validate that tag keys and values are non-empty. + + Args: + v: Tag dictionary to validate. + + Returns: + Validated tag dictionary. + + Raises: + ValueError: If any tag key or value is empty. + """ + for key, value in v.items(): + if not key.strip(): + raise ValueError(f"Tag key cannot be empty. Use 'key: {value}'.") + if not value.strip(): + raise ValueError(f"Tag value cannot be empty for key '{key}'.") + return v + + def __init__(self, **data: Any) -> None: + """Initialize an AWS resource with the ``aws`` provider. + + Sets :attr:`provider` to ``"aws"`` before validation runs. + + Args: + **data: Fields to pass to the Pydantic model constructor. + """ + data.setdefault("provider", "aws") + super().__init__(**data) + + +class GCPResource(CloudResource): + """Stub for a GCP (Google Cloud Platform) infrastructure resource. + + Provider-specific properties are resolved at runtime from + environment variables or explicit ``properties`` on the + :class:`~cleveragents.domain.models.core.resource.Resource` + domain object. This stub stores them as opaque + :attr:`properties` to avoid credential exposure. + + **Constraints**: + + - Inherited from ``CloudResource``: ``provider`` must be ``"gcp"`` + - :attr:`project_id` is required for all GCP resources + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + properties: dict[str, Any] = Field( + default_factory=dict, + description=( + "GCP-specific properties resolved from environment variables " + "or resource configuration. Credentials are resolved via " + "the handler, not stored here." + ), + ) + project_id: str = Field( + ..., + description="GCP project ID (e.g., my-project-123).", + ) + region: str | None = Field( + default=None, + description="GCP region (e.g., us-central1, europe-west1).", + ) + + @field_validator("project_id") + @classmethod + def validate_project_id(cls, v: str) -> str: + """Validate project_id is non-empty. + + Args: + v: Project ID string. + + Returns: + Lowercased, stripped project ID. + """ + v = v.strip() + if not v: + raise ValueError("GCP project_id cannot be empty") + return v.lower() + + @field_validator("region") + @classmethod + def validate_region(cls, v: str | None) -> str | None: + """Validate GCP region is non-empty. + + Args: + v: Region string to validate. + + Returns: + Lowercased, stripped region or None. + """ + if v is not None: + v = v.strip() + if not v: + raise ValueError("GCP region cannot be empty") + return v.lower() + return None + + def __init__(self, **data: Any) -> None: + """Initialize a GCP resource with the ``gcp`` provider. + + Sets :attr:`provider` to ``"gcp"`` before validation runs. + + Args: + **data: Fields to pass to the Pydantic model constructor. + """ + data.setdefault("provider", "gcp") + super().__init__(**data) + + +class AzureResource(CloudResource): + """Stub for an Azure (Microsoft Azure) infrastructure resource. + + Provider-specific properties are resolved at runtime from + environment variables or explicit ``properties`` on the + :class:`~cleveragents.domain.models.core.resource.Resource` + domain object. This stub stores them as opaque + :attr:`properties` to avoid credential exposure. + + **Constraints**: + + - Inherited from ``CloudResource``: ``provider`` must be ``"azure"`` + - :attr:`subscription_id` identifies the Azure subscription + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + properties: dict[str, Any] = Field( + default_factory=dict, + description=( + "Azure-specific properties resolved from environment variables " + "or resource configuration. Credentials are resolved via " + "the handler, not stored here." + ), + ) + subscription_id: str = Field( + ..., + description="Azure subscription ID (GUID).", + ) + tenant_id: str = Field( + ..., + description="Azure AD tenant ID (GUID).", + ) + region: str | None = Field( + default=None, + description="Azure region (e.g., eastus, westeurope).", + ) + + @field_validator("subscription_id") + @classmethod + def validate_subscription_id(cls, v: str) -> str: + """Validate subscription_id is non-empty. + + Args: + v: Subscription ID string. + + Returns: + Lowercased, stripped subscription ID. + """ + v = v.strip() + if not v: + raise ValueError("Azure subscription_id cannot be empty") + return v.lower() + + @field_validator("tenant_id") + @classmethod + def validate_tenant_id(cls, v: str) -> str: + """Validate tenant_id is non-empty. + + Args: + v: Tenant ID string. + + Returns: + Lowercased, stripped tenant ID. + """ + v = v.strip() + if not v: + raise ValueError("Azure tenant_id cannot be empty") + return v.lower() + + @field_validator("region") + @classmethod + def validate_region(cls, v: str | None) -> str | None: + """Validate Azure region is non-empty. + + Args: + v: Region string to validate. + + Returns: + Lowercased, stripped region or None. + """ + if v is not None: + v = v.strip() + if not v: + raise ValueError("Azure region cannot be empty") + return v.lower() + return None + + def __init__(self, **data: Any) -> None: + """Initialize an Azure resource with the ``azure`` provider. + + Sets :attr:`provider` to ``"azure"`` before validation runs. + + Args: + **data: Fields to pass to the Pydantic model constructor. + """ + data.setdefault("provider", "azure") + super().__init__(**data) + + +__all__ = [ + "CLOUD_PROVIDERS", + "AWSResource", + "AzureResource", + "CloudResource", + "GCPResource", +]