Compare commits

...

2 Commits

Author SHA1 Message Date
HAL9000 d6430bcfb9 Merge branch 'feat/v360/cloud-resource-types' into master
Resolve conflicts in CONTRIBUTORS.md by keeping all master entries and
appending PR #10592 cloud infrastructure resource type stubs entry.

Merged PR: cleveragents/cleveragents-core#10592
2026-05-08 08:26:24 +00:00
HAL9000 296a2126fb feat(resources): implement cloud infrastructure resource type stubs (AWS, GCP, Azure)
CI / build (pull_request) Successful in 1m0s
CI / lint (pull_request) Failing after 1m7s
CI / helm (pull_request) Successful in 55s
CI / quality (pull_request) Successful in 1m22s
CI / typecheck (pull_request) Successful in 1m47s
CI / security (pull_request) Successful in 1m47s
CI / push-validation (pull_request) Successful in 21s
CI / e2e_tests (pull_request) Failing after 4m7s
CI / unit_tests (pull_request) Failing after 4m45s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 5m3s
CI / status-check (pull_request) Failing after 3s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1m1s
Implements base CloudResource class with provider-specific stubs for
AWS, GCP, and Azure resources. Includes BDD Behave tests covering
provider validation, auto-provider assignment, tag validation, and
field lowercasing.

- CloudResource base class: common fields (provider, region, account_id, state)
- AWSResource: resource_id, arn, tags with field-level validation
- GCPResource: project_id with automatic lower-casing
- AzureResource: subscription_id, tenant_id with automatic lower-casing
- BDD Behave feature tests with step definitions for all scenarios

Also updates CHANGELOG.md under [Unreleased] and CONTRIBUTORS.md.

Epic #8568 (Resource Types & Container Tool Execution).

ISSUES CLOSED: #8607
2026-05-08 06:21:01 +00:00
6 changed files with 719 additions and 1 deletions
+13
View File
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Cloud infrastructure resource type stubs (AWS, GCP, Azure) (#8607)**: Implemented
a base `CloudResource` Pydantic model with common fields (`provider`, `region`,
`account_id`, `state`) and provider-specific subclasses — `AWSResource`,
`GCPResource`, and `AzureResource`. Each subclass provides auto-provider setting,
field-level validation (provider whitelist, project/subscription lower-casing, tag
validation), and convenience type-check methods (`is_aws()`, `is_gcp()`,
`is_azure()`). BDD Behave feature tests with step definitions cover all provider
instantiation and validation paths. These stubs serve as the domain shape for cloud
infrastructure resources referenced in plans and actors; full cloud SDK integration
is deferred to later milestones as part of Epic #8568 (Resource Types & Container
Tool Execution).
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
Previously the handler logged only the exception type name (e.g.
+1
View File
@@ -35,3 +35,4 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files.
* 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.
+48
View File
@@ -0,0 +1,48 @@
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 raise a ValueError
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 raise a ValueError
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
+223
View File
@@ -0,0 +1,223 @@
"""Behave step definitions for cloud resource type stubs."""
from __future__ import annotations
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 ValueError as exc:
context.exc = exc
@when('an invalid provider "oracle" is set')
def step_set_invalid_provider(context):
"""Set invalid provider and expect ValueError."""
data = dict(context.pending_resource)
data["provider"] = "oracle"
try:
context.exc = None
context.result = CloudResource(**data)
except ValueError 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 raise a ValueError")
def step_raises_value_error(context):
"""Assert a ValueError was raised."""
assert context.exc is not None
assert isinstance(context.exc, ValueError)
# ───────────────────────────── AWSResource ───────────────────────────────
@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 ValueError 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 ValueError 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 ValueError 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 ValueError 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 ValueError 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 ValueError 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"
+18 -1
View File
@@ -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 (
AWSResource,
AzureResource,
CLOUD_PROVIDERS,
CloudResource,
GCPResource,
)
from cleveragents.resource.inheritance import (
MAX_CHAIN_DEPTH,
ResourceTypeCircularInheritanceError,
@@ -15,6 +27,11 @@ from cleveragents.resource.inheritance import (
)
__all__ = [
"AWSResource",
"AzureResource",
"CLOUD_PROVIDERS",
"CloudResource",
"GCPResource",
"MAX_CHAIN_DEPTH",
"ResourceTypeCircularInheritanceError",
"ResourceTypeInheritanceDepthError",
+416
View File
@@ -0,0 +1,416 @@
"""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 (str), region (str), account_id (str) |
| ``AWSResource`` | AWS | access_key_id, secret_access_key, region |
| ``GCPResource`` | GCP | project_id, credentials_path, region |
| ``AzureResource`` | Azure | subscription_id, tenant_id, client_id, region |
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).",
)
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"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:
"""Validate GCP region is non-empty.
Args:
v: Region string.
Returns:
Lowercased, stripped region.
"""
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:
"""Validate Azure region is non-empty.
Args:
v: Region string.
Returns:
Lowercased, stripped region.
"""
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)
# ---------------------------------------------------------------------------
# Rebuild models so that forward references (e.g. ``Any`` from ``__init__``)
# are resolved with the correct module globals before any instantiation runs.
# ---------------------------------------------------------------------------
CloudResource.model_rebuild(_types_namespace={"Any": Any}) # noqa: PLW0603
AWSResource.model_rebuild(_types_namespace={"Any": Any}) # noqa: PLW0603
GCPResource.model_rebuild(_types_namespace={"Any": Any}) # noqa: PLW0603
AzureResource.model_rebuild(_types_namespace={"Any": Any}) # noqa: PLW0603
__all__ = [
"AWSResource",
"AzureResource",
"CLOUD_PROVIDERS",
"CloudResource",
"GCPResource",
]