From 15f000a2dac9ab37ed597128743335b98ceef3b3 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Sat, 7 Feb 2026 17:47:18 +0100 Subject: [PATCH 1/4] feat(landscape): add landscape resource --- .../resources/workspace/git/models.py | 0 .../resources/workspace/git/schema.py | 0 .../resources/workspace/landscape/__init__.py | 31 + .../resources/workspace/landscape/models.py | 168 +++++ .../workspace/landscape/operations.py | 25 + .../resources/workspace/landscape/schemas.py | 619 +++++++++++++++ src/codesphere/resources/workspace/schemas.py | 16 +- tests/integration/test_landscape.py | 439 +++++++++++ .../resources/workspace/landscape/__init__.py | 1 + .../workspace/landscape/test_landscape.py | 706 ++++++++++++++++++ 10 files changed, 2001 insertions(+), 4 deletions(-) create mode 100644 src/codesphere/resources/workspace/git/models.py create mode 100644 src/codesphere/resources/workspace/git/schema.py create mode 100644 src/codesphere/resources/workspace/landscape/__init__.py create mode 100644 src/codesphere/resources/workspace/landscape/operations.py create mode 100644 src/codesphere/resources/workspace/landscape/schemas.py create mode 100644 tests/integration/test_landscape.py create mode 100644 tests/resources/workspace/landscape/__init__.py create mode 100644 tests/resources/workspace/landscape/test_landscape.py diff --git a/src/codesphere/resources/workspace/git/models.py b/src/codesphere/resources/workspace/git/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codesphere/resources/workspace/git/schema.py b/src/codesphere/resources/workspace/git/schema.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codesphere/resources/workspace/landscape/__init__.py b/src/codesphere/resources/workspace/landscape/__init__.py new file mode 100644 index 0000000..67950be --- /dev/null +++ b/src/codesphere/resources/workspace/landscape/__init__.py @@ -0,0 +1,31 @@ +from .models import WorkspaceLandscapeManager +from .schemas import ( + ManagedServiceBuilder, + ManagedServiceConfig, + NetworkConfig, + PathConfig, + PortConfig, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceConfig, + StageConfig, + Step, +) + +__all__ = [ + "WorkspaceLandscapeManager", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "Step", + "StageConfig", + "ReactiveServiceConfig", + "ReactiveServiceBuilder", + "ManagedServiceConfig", + "ManagedServiceBuilder", + "NetworkConfig", + "PortConfig", + "PathConfig", +] diff --git a/src/codesphere/resources/workspace/landscape/models.py b/src/codesphere/resources/workspace/landscape/models.py index e69de29..4009098 100644 --- a/src/codesphere/resources/workspace/landscape/models.py +++ b/src/codesphere/resources/workspace/landscape/models.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from ....core.base import ResourceList +from ....core.handler import _APIOperationExecutor +from ....http_client import APIHttpClient +from .operations import ( + _DEPLOY_OP, + _DEPLOY_WITH_PROFILE_OP, + _SCALE_OP, + _TEARDOWN_OP, +) +from .schemas import Profile, ProfileConfig + +if TYPE_CHECKING: + from ..schemas import CommandOutput + +log = logging.getLogger(__name__) + +# Regex pattern to match ci..yml files +_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") +# Pattern for valid profile names +_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + + +class WorkspaceLandscapeManager(_APIOperationExecutor): + """Manager for workspace landscape operations (Multi Server Deployments).""" + + def __init__(self, http_client: APIHttpClient, workspace_id: int): + self._http_client = http_client + self._workspace_id = workspace_id + self.id = workspace_id + + async def list_profiles(self) -> ResourceList[Profile]: + """List all available deployment profiles in the workspace. + + Profiles are discovered by listing files matching the pattern ci..yml + in the workspace root directory. + + Returns: + ResourceList of Profile objects. + """ + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + command_data = CommandInput(command="ls -1 *.yml 2>/dev/null || true") + result: CommandOutput = await self._execute_operation( + _EXECUTE_COMMAND_OP, data=command_data + ) + + profiles: List[Profile] = [] + if result.output: + for line in result.output.strip().split("\n"): + line = line.strip() + if match := _PROFILE_FILE_PATTERN.match(line): + profile_name = match.group(1) + profiles.append(Profile(name=profile_name)) + + return ResourceList[Profile](root=profiles) + + async def save_profile(self, name: str, config: Union[ProfileConfig, str]) -> None: + """Save a profile configuration to the workspace. + + Args: + name: Profile name (must match pattern ^[A-Za-z0-9_-]+$). + config: ProfileConfig instance or YAML string. + + Raises: + ValueError: If the profile name is invalid. + """ + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + # Convert ProfileConfig to YAML if needed + if isinstance(config, ProfileConfig): + yaml_content = config.to_yaml() + else: + yaml_content = config + + # Escape single quotes in YAML content for shell + escaped_content = yaml_content.replace("'", "'\"'\"'") + + # Write the profile file + filename = f"ci.{name}.yml" + command = f"cat > {filename} << 'PROFILE_EOF'\n{yaml_content}PROFILE_EOF" + + command_data = CommandInput(command=command) + await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) + + async def get_profile(self, name: str) -> str: + """Get the raw YAML content of a profile. + + Args: + name: Profile name. + + Returns: + YAML content of the profile as a string. + + Raises: + ValueError: If the profile name is invalid. + """ + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + filename = f"ci.{name}.yml" + command_data = CommandInput(command=f"cat {filename}") + result: CommandOutput = await self._execute_operation( + _EXECUTE_COMMAND_OP, data=command_data + ) + + return result.output + + async def delete_profile(self, name: str) -> None: + """Delete a profile from the workspace. + + Args: + name: Profile name to delete. + + Raises: + ValueError: If the profile name is invalid. + """ + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + filename = f"ci.{name}.yml" + command_data = CommandInput(command=f"rm -f {filename}") + await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) + + async def deploy(self, profile: Optional[str] = None) -> None: + """Deploy the landscape. + + Args: + profile: Optional deployment profile name (must match pattern ^[A-Za-z0-9-_]+$). + """ + if profile is not None: + await self._execute_operation(_DEPLOY_WITH_PROFILE_OP, profile=profile) + else: + await self._execute_operation(_DEPLOY_OP) + + async def teardown(self) -> None: + """Teardown the landscape.""" + await self._execute_operation(_TEARDOWN_OP) + + async def scale(self, services: Dict[str, int]) -> None: + """Scale landscape services. + + Args: + services: A dictionary mapping service names to replica counts (minimum 1). + """ + await self._execute_operation(_SCALE_OP, data=services) diff --git a/src/codesphere/resources/workspace/landscape/operations.py b/src/codesphere/resources/workspace/landscape/operations.py new file mode 100644 index 0000000..9197b09 --- /dev/null +++ b/src/codesphere/resources/workspace/landscape/operations.py @@ -0,0 +1,25 @@ +from ....core.operations import APIOperation + +_DEPLOY_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy", + response_model=type(None), +) + +_DEPLOY_WITH_PROFILE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy/{profile}", + response_model=type(None), +) + +_TEARDOWN_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}/landscape/teardown", + response_model=type(None), +) + +_SCALE_OP = APIOperation( + method="PATCH", + endpoint_template="/workspaces/{id}/landscape/scale", + response_model=type(None), +) diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py new file mode 100644 index 0000000..628f3c1 --- /dev/null +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +import yaml +from pydantic import BaseModel, Field + +from ....core.base import CamelModel + + +class Profile(BaseModel): + """Landscape deployment profile model.""" + + name: str + + +class Step(CamelModel): + """A step in a pipeline stage.""" + + name: Optional[str] = None + command: str + + +class PortConfig(CamelModel): + """Port configuration for a reactive service.""" + + port: int = Field(ge=1, le=65535) + is_public: bool = False + + +class PathConfig(CamelModel): + """Path routing configuration for a reactive service.""" + + port: int = Field(ge=1, le=65535) + path: str + strip_path: Optional[bool] = None + + +class NetworkConfig(CamelModel): + """Network configuration for a reactive service.""" + + ports: List[PortConfig] = Field(default_factory=list) + paths: List[PathConfig] = Field(default_factory=list) + + +class ReactiveServiceConfig(CamelModel): + """Configuration for a reactive (custom) service in the run stage.""" + + steps: List[Step] = Field(default_factory=list) + env: Optional[Dict[str, str]] = None + plan: Optional[int] = None + replicas: int = 1 + base_image: Optional[str] = None + run_as_user: Optional[int] = Field(default=None, ge=0, le=65534) + run_as_group: Optional[int] = Field(default=None, ge=0, le=65534) + mount_sub_path: Optional[str] = None + health_endpoint: Optional[str] = None + network: Optional[NetworkConfig] = None + + +class ManagedServiceConfig(CamelModel): + """Configuration for a managed service (e.g., database) in the run stage.""" + + provider: str + plan: str + config: Optional[Dict[str, Any]] = None + secrets: Optional[Dict[str, str]] = None + + +class StageConfig(CamelModel): + """Configuration for a pipeline stage (prepare/test).""" + + steps: List[Step] = Field(default_factory=list) + + +class ProfileConfig(CamelModel): + """Complete pipeline configuration for a landscape profile (schema v0.2).""" + + schema_version: Literal["v0.2"] = Field(default="v0.2", alias="schemaVersion") + prepare: StageConfig = Field(default_factory=StageConfig) + test: StageConfig = Field(default_factory=StageConfig) + run: Dict[str, ReactiveServiceConfig | ManagedServiceConfig] = Field( + default_factory=dict + ) + + def to_yaml(self, *, exclude_none: bool = True) -> str: + """Export the profile configuration as YAML. + + Args: + exclude_none: Exclude fields with None values if True. + + Returns: + YAML string representation of the profile. + """ + data = self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json") + return yaml.safe_dump( + data, default_flow_style=False, allow_unicode=True, sort_keys=False + ) + + +# ============================================================================= +# Fluent Builder Classes +# ============================================================================= + + +class StepBuilder: + """Fluent builder for pipeline steps.""" + + def __init__(self, command: str, name: Optional[str] = None): + self._command = command + self._name = name + + def build(self) -> Step: + """Build the Step instance.""" + return Step(command=self._command, name=self._name) + + +class PortBuilder: + """Fluent builder for port configuration.""" + + def __init__(self, port: int): + self._port = port + self._is_public = False + + def public(self, is_public: bool = True) -> PortBuilder: + """Set whether the port is publicly accessible.""" + self._is_public = is_public + return self + + def build(self) -> PortConfig: + """Build the PortConfig instance.""" + return PortConfig(port=self._port, is_public=self._is_public) + + +class PathBuilder: + """Fluent builder for path routing configuration.""" + + def __init__(self, path: str, port: int): + self._path = path + self._port = port + self._strip_path: Optional[bool] = None + + def strip_path(self, strip: bool = True) -> PathBuilder: + """Set whether to strip the path prefix when forwarding.""" + self._strip_path = strip + return self + + def build(self) -> PathConfig: + """Build the PathConfig instance.""" + return PathConfig(port=self._port, path=self._path, strip_path=self._strip_path) + + +class ReactiveServiceBuilder: + """Fluent builder for reactive (custom) service configuration.""" + + def __init__(self, name: str): + self._name = name + self._steps: List[Step] = [] + self._env: Dict[str, str] = {} + self._plan: Optional[int] = None + self._replicas: int = 1 + self._base_image: Optional[str] = None + self._run_as_user: Optional[int] = None + self._run_as_group: Optional[int] = None + self._mount_sub_path: Optional[str] = None + self._health_endpoint: Optional[str] = None + self._ports: List[PortConfig] = [] + self._paths: List[PathConfig] = [] + + @property + def name(self) -> str: + """Get the service name.""" + return self._name + + def add_step( + self, command: str, name: Optional[str] = None + ) -> ReactiveServiceBuilder: + """Add a step to the service. + + Args: + command: The command to execute. + name: Optional name for the step. + """ + self._steps.append(Step(command=command, name=name)) + return self + + def env(self, key: str, value: str) -> ReactiveServiceBuilder: + """Add an environment variable. + + Args: + key: Environment variable name. + value: Environment variable value. + """ + self._env[key] = value + return self + + def envs(self, env_vars: Dict[str, str]) -> ReactiveServiceBuilder: + """Add multiple environment variables. + + Args: + env_vars: Dictionary of environment variables. + """ + self._env.update(env_vars) + return self + + def plan(self, plan_id: int) -> ReactiveServiceBuilder: + """Set the plan ID for the service. + + Args: + plan_id: The workspace plan ID. + """ + self._plan = plan_id + return self + + def replicas(self, count: int) -> ReactiveServiceBuilder: + """Set the number of replicas. + + Args: + count: Number of replicas (minimum 1). + """ + self._replicas = max(1, count) + return self + + def base_image(self, image: str) -> ReactiveServiceBuilder: + """Set the base image for the service. + + Args: + image: Docker image reference. + """ + self._base_image = image + return self + + def run_as( + self, user: Optional[int] = None, group: Optional[int] = None + ) -> ReactiveServiceBuilder: + """Set the user and group to run the service as. + + Args: + user: User ID (0-65534). + group: Group ID (0-65534). + """ + self._run_as_user = user + self._run_as_group = group + return self + + def mount_sub_path(self, path: str) -> ReactiveServiceBuilder: + """Set the mount sub-path. + + Args: + path: Sub-path to mount. + """ + self._mount_sub_path = path + return self + + def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilder: + """Set the health check endpoint. + + Args: + endpoint: Health check endpoint path. + """ + self._health_endpoint = endpoint + return self + + def add_port(self, port: int, *, public: bool = False) -> ReactiveServiceBuilder: + """Add a port to the service. + + Args: + port: Port number (1-65535). + public: Whether the port is publicly accessible. + """ + self._ports.append(PortConfig(port=port, is_public=public)) + return self + + def add_path( + self, path: str, port: int, *, strip_path: Optional[bool] = None + ) -> ReactiveServiceBuilder: + """Add a path routing rule. + + Args: + path: URL path to route. + port: Port to forward to. + strip_path: Whether to strip the path prefix. + """ + self._paths.append(PathConfig(port=port, path=path, strip_path=strip_path)) + return self + + def build(self) -> tuple[str, ReactiveServiceConfig]: + """Build the service configuration. + + Returns: + Tuple of (service_name, ReactiveServiceConfig). + """ + network = None + if self._ports or self._paths: + network = NetworkConfig(ports=self._ports, paths=self._paths) + + config = ReactiveServiceConfig( + steps=self._steps, + env=self._env if self._env else None, + plan=self._plan, + replicas=self._replicas, + base_image=self._base_image, + run_as_user=self._run_as_user, + run_as_group=self._run_as_group, + mount_sub_path=self._mount_sub_path, + health_endpoint=self._health_endpoint, + network=network, + ) + return self._name, config + + +class ManagedServiceBuilder: + """Fluent builder for managed service configuration.""" + + def __init__(self, name: str, provider: str, plan: str): + self._name = name + self._provider = provider + self._plan = plan + self._config: Dict[str, Any] = {} + self._secrets: Dict[str, str] = {} + + @property + def name(self) -> str: + """Get the service name.""" + return self._name + + def config(self, key: str, value: Any) -> ManagedServiceBuilder: + """Add a configuration option. + + Args: + key: Configuration key. + value: Configuration value. + """ + self._config[key] = value + return self + + def configs(self, config: Dict[str, Any]) -> ManagedServiceBuilder: + """Add multiple configuration options. + + Args: + config: Dictionary of configuration options. + """ + self._config.update(config) + return self + + def secret(self, key: str, value: str) -> ManagedServiceBuilder: + """Add a secret. + + Args: + key: Secret key. + value: Secret value (or vault reference). + """ + self._secrets[key] = value + return self + + def secrets(self, secrets: Dict[str, str]) -> ManagedServiceBuilder: + """Add multiple secrets. + + Args: + secrets: Dictionary of secrets. + """ + self._secrets.update(secrets) + return self + + def build(self) -> tuple[str, ManagedServiceConfig]: + """Build the managed service configuration. + + Returns: + Tuple of (service_name, ManagedServiceConfig). + """ + config = ManagedServiceConfig( + provider=self._provider, + plan=self._plan, + config=self._config if self._config else None, + secrets=self._secrets if self._secrets else None, + ) + return self._name, config + + +class ProfileBuilder: + """Fluent builder for creating landscape profile configurations. + + Example: + ```python + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .add_step("npm run build") + .done() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000, public=True) + .add_path("/api", port=3000) + .replicas(2) + .env("NODE_ENV", "production") + .done() + .add_managed_service("db", provider="postgres", plan="small") + .config("max_connections", 100) + .done() + .build() + ) + + # Save to workspace + await workspace.landscape.save_profile("production", profile) + ``` + """ + + def __init__(self) -> None: + self._prepare_steps: List[Step] = [] + self._test_steps: List[Step] = [] + self._services: Dict[str, ReactiveServiceConfig | ManagedServiceConfig] = {} + + def prepare(self) -> PrepareStageBuilder: + """Configure the prepare stage. + + Returns: + A PrepareStageBuilder for fluent configuration. + """ + return PrepareStageBuilder(self) + + def test(self) -> TestStageBuilder: + """Configure the test stage. + + Returns: + A TestStageBuilder for fluent configuration. + """ + return TestStageBuilder(self) + + def add_reactive_service(self, name: str) -> ReactiveServiceBuilderContext: + """Add a reactive (custom) service to the run stage. + + Args: + name: Unique name for the service. + + Returns: + A ReactiveServiceBuilderContext for fluent configuration. + """ + return ReactiveServiceBuilderContext(self, name) + + def add_managed_service( + self, name: str, *, provider: str, plan: str + ) -> ManagedServiceBuilderContext: + """Add a managed service (e.g., database) to the run stage. + + Args: + name: Unique name for the service. + provider: Service provider (e.g., "postgres", "redis"). + plan: Service plan (e.g., "small", "medium"). + + Returns: + A ManagedServiceBuilderContext for fluent configuration. + """ + return ManagedServiceBuilderContext(self, name, provider, plan) + + def build(self) -> ProfileConfig: + """Build the complete profile configuration. + + Returns: + A ProfileConfig instance ready to be saved. + """ + return ProfileConfig( + prepare=StageConfig(steps=self._prepare_steps), + test=StageConfig(steps=self._test_steps), + run=self._services, + ) + + +class PrepareStageBuilder: + """Builder context for the prepare stage.""" + + def __init__(self, parent: ProfileBuilder): + self._parent = parent + + def add_step(self, command: str, name: Optional[str] = None) -> PrepareStageBuilder: + """Add a step to the prepare stage. + + Args: + command: The command to execute. + name: Optional name for the step. + """ + self._parent._prepare_steps.append(Step(command=command, name=name)) + return self + + def done(self) -> ProfileBuilder: + """Return to the parent ProfileBuilder.""" + return self._parent + + +class TestStageBuilder: + """Builder context for the test stage.""" + + def __init__(self, parent: ProfileBuilder): + self._parent = parent + + def add_step(self, command: str, name: Optional[str] = None) -> TestStageBuilder: + """Add a step to the test stage. + + Args: + command: The command to execute. + name: Optional name for the step. + """ + self._parent._test_steps.append(Step(command=command, name=name)) + return self + + def done(self) -> ProfileBuilder: + """Return to the parent ProfileBuilder.""" + return self._parent + + +class ReactiveServiceBuilderContext: + """Builder context for a reactive service within a ProfileBuilder.""" + + def __init__(self, parent: ProfileBuilder, name: str): + self._parent = parent + self._builder = ReactiveServiceBuilder(name) + + def add_step( + self, command: str, name: Optional[str] = None + ) -> ReactiveServiceBuilderContext: + """Add a step to the service.""" + self._builder.add_step(command, name) + return self + + def env(self, key: str, value: str) -> ReactiveServiceBuilderContext: + """Add an environment variable.""" + self._builder.env(key, value) + return self + + def envs(self, env_vars: Dict[str, str]) -> ReactiveServiceBuilderContext: + """Add multiple environment variables.""" + self._builder.envs(env_vars) + return self + + def plan(self, plan_id: int) -> ReactiveServiceBuilderContext: + """Set the plan ID.""" + self._builder.plan(plan_id) + return self + + def replicas(self, count: int) -> ReactiveServiceBuilderContext: + """Set the number of replicas.""" + self._builder.replicas(count) + return self + + def base_image(self, image: str) -> ReactiveServiceBuilderContext: + """Set the base image.""" + self._builder.base_image(image) + return self + + def run_as( + self, user: Optional[int] = None, group: Optional[int] = None + ) -> ReactiveServiceBuilderContext: + """Set user and group IDs.""" + self._builder.run_as(user, group) + return self + + def mount_sub_path(self, path: str) -> ReactiveServiceBuilderContext: + """Set the mount sub-path.""" + self._builder.mount_sub_path(path) + return self + + def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilderContext: + """Set the health check endpoint.""" + self._builder.health_endpoint(endpoint) + return self + + def add_port( + self, port: int, *, public: bool = False + ) -> ReactiveServiceBuilderContext: + """Add a port to the service.""" + self._builder.add_port(port, public=public) + return self + + def add_path( + self, path: str, port: int, *, strip_path: Optional[bool] = None + ) -> ReactiveServiceBuilderContext: + """Add a path routing rule.""" + self._builder.add_path(path, port, strip_path=strip_path) + return self + + def done(self) -> ProfileBuilder: + """Finalize the service and return to the parent ProfileBuilder.""" + name, config = self._builder.build() + self._parent._services[name] = config + return self._parent + + +class ManagedServiceBuilderContext: + """Builder context for a managed service within a ProfileBuilder.""" + + def __init__(self, parent: ProfileBuilder, name: str, provider: str, plan: str): + self._parent = parent + self._builder = ManagedServiceBuilder(name, provider, plan) + + def config(self, key: str, value: Any) -> ManagedServiceBuilderContext: + """Add a configuration option.""" + self._builder.config(key, value) + return self + + def configs(self, config: Dict[str, Any]) -> ManagedServiceBuilderContext: + """Add multiple configuration options.""" + self._builder.configs(config) + return self + + def secret(self, key: str, value: str) -> ManagedServiceBuilderContext: + """Add a secret.""" + self._builder.secret(key, value) + return self + + def secrets(self, secrets: Dict[str, str]) -> ManagedServiceBuilderContext: + """Add multiple secrets.""" + self._builder.secrets(secrets) + return self + + def done(self) -> ProfileBuilder: + """Finalize the service and return to the parent ProfileBuilder.""" + name, config = self._builder.build() + self._parent._services[name] = config + return self._parent diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index a8e4528..f6bfd20 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -1,12 +1,14 @@ from __future__ import annotations -from functools import cached_property + import logging -from typing import Dict, Optional, List +from functools import cached_property +from typing import Dict, List, Optional -from .envVars import EnvVar, WorkspaceEnvVarManager -from ...core.base import CamelModel from ...core import _APIOperationExecutor +from ...core.base import CamelModel from ...utils import update_model_fields +from .envVars import EnvVar, WorkspaceEnvVarManager +from .landscape import WorkspaceLandscapeManager log = logging.getLogger(__name__) @@ -106,3 +108,9 @@ async def execute_command( def env_vars(self) -> WorkspaceEnvVarManager: http_client = self.validate_http_client() return WorkspaceEnvVarManager(http_client, workspace_id=self.id) + + @cached_property + def landscape(self) -> WorkspaceLandscapeManager: + """Manager for landscape operations (Multi Server Deployments).""" + http_client = self.validate_http_client() + return WorkspaceLandscapeManager(http_client, workspace_id=self.id) diff --git a/tests/integration/test_landscape.py b/tests/integration/test_landscape.py new file mode 100644 index 0000000..4e462ce --- /dev/null +++ b/tests/integration/test_landscape.py @@ -0,0 +1,439 @@ +import pytest + +from codesphere import CodesphereSDK +from codesphere.core.base import ResourceList +from codesphere.resources.workspace import Workspace +from codesphere.resources.workspace.landscape import ( + Profile, + ProfileBuilder, +) + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestLandscapeProfilesIntegration: + """Integration tests for landscape profile listing.""" + + async def test_list_profiles_returns_resource_list( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles should return a ResourceList.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + profiles = await workspace.landscape.list_profiles() + + assert isinstance(profiles, ResourceList) + + async def test_list_profiles_empty_workspace( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles on a fresh workspace should return empty or existing profiles.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + profiles = await workspace.landscape.list_profiles() + + # Fresh workspace may have no profiles, which is valid + assert isinstance(profiles, ResourceList) + assert len(profiles) >= 0 + + async def test_list_profiles_after_creating_profile_file( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles should find a profile after creating a ci..yml file.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Create a test profile file + profile_name = "sdk-test-profile" + create_result = await workspace.execute_command( + f"echo 'version: 1' > ci.{profile_name}.yml" + ) + + try: + profiles = await workspace.landscape.list_profiles() + + profile_names = [p.name for p in profiles] + assert profile_name in profile_names + + # Verify the profile is a Profile instance + matching_profile = next(p for p in profiles if p.name == profile_name) + assert isinstance(matching_profile, Profile) + + finally: + # Cleanup: remove the test profile file + await workspace.execute_command(f"rm -f ci.{profile_name}.yml") + + async def test_list_profiles_with_multiple_profile_files( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles should find multiple profiles.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Create multiple test profile files + profile_names = ["test-profile-1", "test-profile-2", "test_profile_3"] + for name in profile_names: + await workspace.execute_command(f"echo 'version: 1' > ci.{name}.yml") + + try: + profiles = await workspace.landscape.list_profiles() + + found_names = [p.name for p in profiles] + for expected_name in profile_names: + assert expected_name in found_names, ( + f"Profile {expected_name} not found" + ) + + finally: + # Cleanup: remove all test profile files + for name in profile_names: + await workspace.execute_command(f"rm -f ci.{name}.yml") + + async def test_list_profiles_ignores_non_profile_yml_files( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles should not include non-profile yml files.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Create a profile file and a non-profile yml file + await workspace.execute_command("echo 'version: 1' > ci.valid-profile.yml") + await workspace.execute_command("echo 'key: value' > config.yml") + await workspace.execute_command("echo 'services: []' > docker-compose.yml") + + try: + profiles = await workspace.landscape.list_profiles() + + profile_names = [p.name for p in profiles] + assert "valid-profile" in profile_names + # These should NOT be in the list as they don't match ci..yml pattern + assert "config" not in profile_names + assert "docker-compose" not in profile_names + + finally: + # Cleanup + await workspace.execute_command( + "rm -f ci.valid-profile.yml config.yml docker-compose.yml" + ) + + async def test_list_profiles_iterable( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """list_profiles result should be iterable.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Create a test profile + await workspace.execute_command("echo 'version: 1' > ci.iter-test.yml") + + try: + profiles = await workspace.landscape.list_profiles() + + # Test iteration + profile_list = list(profiles) + assert isinstance(profile_list, list) + + # Test indexing + if len(profiles) > 0: + first_profile = profiles[0] + assert isinstance(first_profile, Profile) + + finally: + await workspace.execute_command("rm -f ci.iter-test.yml") + + +class TestLandscapeManagerAccess: + """Integration tests for accessing the landscape manager.""" + + async def test_workspace_has_landscape_property( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Workspace should have a landscape property.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + assert hasattr(workspace, "landscape") + assert workspace.landscape is not None + + async def test_landscape_manager_is_cached( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Landscape manager should be cached on the workspace instance.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + manager1 = workspace.landscape + manager2 = workspace.landscape + + assert manager1 is manager2 + + +class TestSaveProfileIntegration: + """Integration tests for saving landscape profiles.""" + + async def test_save_profile_with_builder( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """save_profile should create a profile file using ProfileBuilder.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-builder-test" + + profile = ( + ProfileBuilder() + .prepare() + .add_step("echo 'Installing dependencies'") + .done() + .add_reactive_service("web") + .add_step("echo 'Starting server'") + .add_port(3000, public=True) + .replicas(1) + .done() + .build() + ) + + try: + await workspace.landscape.save_profile(profile_name, profile) + + # Verify profile was created + profiles = await workspace.landscape.list_profiles() + profile_names = [p.name for p in profiles] + assert profile_name in profile_names + + finally: + await workspace.landscape.delete_profile(profile_name) + + async def test_save_profile_with_yaml_string( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """save_profile should accept a raw YAML string.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-yaml-test" + + yaml_content = """schemaVersion: v0.2 +prepare: + steps: + - command: echo 'test' +test: + steps: [] +run: {} +""" + + try: + await workspace.landscape.save_profile(profile_name, yaml_content) + + profiles = await workspace.landscape.list_profiles() + profile_names = [p.name for p in profiles] + assert profile_name in profile_names + + finally: + await workspace.landscape.delete_profile(profile_name) + + async def test_save_profile_overwrites_existing( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """save_profile should overwrite an existing profile.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-overwrite-test" + + # Create initial profile + profile_v1 = ( + ProfileBuilder().prepare().add_step("echo 'version 1'").done().build() + ) + + # Create updated profile + profile_v2 = ( + ProfileBuilder().prepare().add_step("echo 'version 2'").done().build() + ) + + try: + await workspace.landscape.save_profile(profile_name, profile_v1) + await workspace.landscape.save_profile(profile_name, profile_v2) + + # Verify content was updated + content = await workspace.landscape.get_profile(profile_name) + assert "version 2" in content + + finally: + await workspace.landscape.delete_profile(profile_name) + + +class TestGetProfileIntegration: + """Integration tests for getting landscape profile content.""" + + async def test_get_profile_returns_yaml_content( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """get_profile should return the YAML content of a saved profile.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-get-test" + + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .done() + .add_reactive_service("api") + .add_step("npm start") + .add_port(8080) + .env("NODE_ENV", "production") + .done() + .build() + ) + + try: + await workspace.landscape.save_profile(profile_name, profile) + + content = await workspace.landscape.get_profile(profile_name) + + assert "schemaVersion: v0.2" in content + assert "npm install" in content + assert "api:" in content + assert "NODE_ENV" in content + + finally: + await workspace.landscape.delete_profile(profile_name) + + +class TestDeleteProfileIntegration: + """Integration tests for deleting landscape profiles.""" + + async def test_delete_profile_removes_file( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """delete_profile should remove the profile file.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-delete-test" + + profile = ProfileBuilder().build() + await workspace.landscape.save_profile(profile_name, profile) + + # Verify it exists + profiles = await workspace.landscape.list_profiles() + assert profile_name in [p.name for p in profiles] + + # Delete it + await workspace.landscape.delete_profile(profile_name) + + # Verify it's gone + profiles = await workspace.landscape.list_profiles() + assert profile_name not in [p.name for p in profiles] + + async def test_delete_nonexistent_profile_no_error( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """delete_profile should not raise an error for non-existent profiles.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + + # Should not raise + await workspace.landscape.delete_profile("nonexistent-profile-xyz") + + +class TestProfileBuilderIntegration: + """Integration tests for ProfileBuilder with real workspaces.""" + + async def test_complex_profile_roundtrip( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """A complex profile should survive save and retrieve.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-complex-test" + + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm ci", name="Install") + .add_step("npm run build", name="Build") + .done() + .test() + .add_step("npm test") + .done() + .add_reactive_service("frontend") + .add_step("npm run serve") + .add_port(3000, public=True) + .add_path("/", port=3000) + .replicas(2) + .env("NODE_ENV", "production") + .health_endpoint("/health") + .done() + .add_reactive_service("backend") + .add_step("python -m uvicorn main:app") + .add_port(8000) + .add_path("/api", port=8000, strip_path=True) + .envs({"PYTHONPATH": "/app", "LOG_LEVEL": "info"}) + .done() + .add_managed_service("database", provider="postgres", plan="small") + .config("max_connections", 50) + .done() + .build() + ) + + try: + await workspace.landscape.save_profile(profile_name, profile) + + content = await workspace.landscape.get_profile(profile_name) + + # Verify key elements are present + assert "schemaVersion: v0.2" in content + assert "frontend:" in content + assert "backend:" in content + assert "database:" in content + assert "npm ci" in content + assert "replicas: 2" in content + assert "postgres" in content + + finally: + await workspace.landscape.delete_profile(profile_name) + + async def test_profile_with_special_characters_in_env( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + ): + """Profile with special characters in env values should work.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-special-chars-test" + + profile = ( + ProfileBuilder() + .add_reactive_service("app") + .add_step("npm start") + .add_port(3000) + .env("DATABASE_URL", "postgres://user:p@ss=word@localhost:5432/db") + .env("API_KEY", "sk-1234567890abcdef") + .done() + .build() + ) + + try: + await workspace.landscape.save_profile(profile_name, profile) + + content = await workspace.landscape.get_profile(profile_name) + assert "DATABASE_URL" in content + assert "API_KEY" in content + + finally: + await workspace.landscape.delete_profile(profile_name) diff --git a/tests/resources/workspace/landscape/__init__.py b/tests/resources/workspace/landscape/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/resources/workspace/landscape/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/resources/workspace/landscape/test_landscape.py b/tests/resources/workspace/landscape/test_landscape.py new file mode 100644 index 0000000..d2ed3ae --- /dev/null +++ b/tests/resources/workspace/landscape/test_landscape.py @@ -0,0 +1,706 @@ +import pytest + +from codesphere.core.base import ResourceList +from codesphere.resources.workspace.landscape import ( + ManagedServiceBuilder, + ManagedServiceConfig, + NetworkConfig, + PathConfig, + PortConfig, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceConfig, + StageConfig, + Step, + WorkspaceLandscapeManager, +) + + +class TestWorkspaceLandscapeManager: + """Tests for the WorkspaceLandscapeManager class.""" + + @pytest.fixture + def landscape_manager(self, mock_http_client_for_resource): + """Create a WorkspaceLandscapeManager with mock HTTP client.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + return manager, mock_client + + @pytest.mark.asyncio + async def test_deploy_without_profile(self, landscape_manager): + """Deploy without profile should call the basic deploy endpoint.""" + manager, mock_client = landscape_manager + + await manager.deploy() + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/landscape/deploy" + + @pytest.mark.asyncio + async def test_deploy_with_profile(self, mock_http_client_for_resource): + """Deploy with profile should call the profile-specific endpoint.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + await manager.deploy(profile="my-profile") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert ( + call_args.kwargs.get("endpoint") + == "/workspaces/72678/landscape/deploy/my-profile" + ) + + @pytest.mark.asyncio + async def test_teardown(self, landscape_manager): + """Teardown should call the teardown endpoint.""" + manager, mock_client = landscape_manager + + await manager.teardown() + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "DELETE" + assert ( + call_args.kwargs.get("endpoint") == "/workspaces/72678/landscape/teardown" + ) + + @pytest.mark.asyncio + async def test_scale_services(self, mock_http_client_for_resource): + """Scale should call the scale endpoint with service configuration.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + services = {"web": 3, "worker": 2} + await manager.scale(services=services) + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "PATCH" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/landscape/scale" + assert call_args.kwargs.get("json") == {"web": 3, "worker": 2} + + @pytest.mark.asyncio + async def test_scale_single_service(self, mock_http_client_for_resource): + """Scale should work with a single service.""" + mock_client = mock_http_client_for_resource(None) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + services = {"api": 5} + await manager.scale(services=services) + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("json") == {"api": 5} + + +class TestListProfiles: + """Tests for the list_profiles method.""" + + @pytest.fixture + def mock_command_response(self): + """Factory to create mock command output responses.""" + + def _create(output: str, error: str = ""): + return { + "command": "ls -1 *.yml 2>/dev/null || true", + "workingDir": "/home/user", + "output": output, + "error": error, + } + + return _create + + @pytest.mark.asyncio + async def test_list_profiles_with_multiple_profiles( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should return profiles from ci..yml files.""" + response_data = mock_command_response( + "ci.production.yml\nci.staging.yml\nci.dev-test.yml\n" + ) + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.list_profiles() + + assert isinstance(result, ResourceList) + assert len(result) == 3 + assert result[0].name == "production" + assert result[1].name == "staging" + assert result[2].name == "dev-test" + + @pytest.mark.asyncio + async def test_list_profiles_with_single_profile( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should work with a single profile.""" + response_data = mock_command_response("ci.main.yml\n") + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.list_profiles() + + assert len(result) == 1 + assert result[0].name == "main" + + @pytest.mark.asyncio + async def test_list_profiles_with_no_profiles( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should return empty list when no profiles exist.""" + response_data = mock_command_response("") + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.list_profiles() + + assert isinstance(result, ResourceList) + assert len(result) == 0 + + @pytest.mark.asyncio + async def test_list_profiles_filters_non_profile_yml_files( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should filter out non-profile yml files.""" + response_data = mock_command_response( + "ci.production.yml\nconfig.yml\ndocker-compose.yml\nci.staging.yml\n" + ) + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.list_profiles() + + assert len(result) == 2 + profile_names = [p.name for p in result] + assert "production" in profile_names + assert "staging" in profile_names + + @pytest.mark.asyncio + async def test_list_profiles_with_underscore_in_name( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should handle underscores in profile names.""" + response_data = mock_command_response("ci.my_profile.yml\n") + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.list_profiles() + + assert len(result) == 1 + assert result[0].name == "my_profile" + + @pytest.mark.asyncio + async def test_list_profiles_calls_execute_endpoint( + self, mock_http_client_for_resource, mock_command_response + ): + """list_profiles should call the execute command endpoint.""" + response_data = mock_command_response("") + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + await manager.list_profiles() + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/execute" + + +class TestProfileModel: + """Tests for the Profile model.""" + + def test_create_profile(self): + """Profile should be created with a name.""" + profile = Profile(name="production") + + assert profile.name == "production" + + def test_profile_from_dict(self): + """Profile should be created from dictionary.""" + profile = Profile.model_validate({"name": "staging"}) + + assert profile.name == "staging" + + def test_profile_dump(self): + """Profile should dump to dictionary correctly.""" + profile = Profile(name="dev") + dumped = profile.model_dump() + + assert dumped == {"name": "dev"} + + +class TestWorkspaceLandscapeManagerAccess: + """Tests for accessing the landscape manager from a Workspace instance.""" + + @pytest.mark.asyncio + async def test_workspace_landscape_property(self, workspace_model_factory): + """Workspace should expose a landscape property.""" + workspace, _ = workspace_model_factory() + + landscape_manager = workspace.landscape + + assert isinstance(landscape_manager, WorkspaceLandscapeManager) + + @pytest.mark.asyncio + async def test_workspace_landscape_is_cached(self, workspace_model_factory): + """Landscape manager should be cached on the workspace.""" + workspace, _ = workspace_model_factory() + + manager1 = workspace.landscape + manager2 = workspace.landscape + + assert manager1 is manager2 + + +class TestSaveProfile: + """Tests for the save_profile method.""" + + @pytest.fixture + def mock_command_response(self): + """Factory to create mock command output responses.""" + + def _create(output: str = "", error: str = ""): + return { + "command": "", + "workingDir": "/home/user", + "output": output, + "error": error, + } + + return _create + + @pytest.mark.asyncio + async def test_save_profile_with_profile_config( + self, mock_http_client_for_resource, mock_command_response + ): + """save_profile should write a ProfileConfig to a file.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + config = ProfileConfig() + await manager.save_profile("production", config) + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/execute" + + @pytest.mark.asyncio + async def test_save_profile_with_yaml_string( + self, mock_http_client_for_resource, mock_command_response + ): + """save_profile should accept a raw YAML string.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + yaml_content = "schemaVersion: v0.2\nprepare:\n steps: []\n" + await manager.save_profile("staging", yaml_content) + + mock_client.request.assert_awaited_once() + + @pytest.mark.asyncio + async def test_save_profile_invalid_name_raises_error( + self, mock_http_client_for_resource, mock_command_response + ): + """save_profile should raise ValueError for invalid profile names.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + with pytest.raises(ValueError, match="Invalid profile name"): + await manager.save_profile("invalid/name", ProfileConfig()) + + @pytest.mark.asyncio + async def test_save_profile_invalid_name_with_spaces( + self, mock_http_client_for_resource, mock_command_response + ): + """save_profile should reject names with spaces.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + with pytest.raises(ValueError): + await manager.save_profile("my profile", ProfileConfig()) + + +class TestGetProfile: + """Tests for the get_profile method.""" + + @pytest.fixture + def mock_command_response(self): + """Factory to create mock command output responses.""" + + def _create(output: str = "", error: str = ""): + return { + "command": "", + "workingDir": "/home/user", + "output": output, + "error": error, + } + + return _create + + @pytest.mark.asyncio + async def test_get_profile_returns_yaml_content( + self, mock_http_client_for_resource, mock_command_response + ): + """get_profile should return the YAML content of a profile.""" + yaml_content = "schemaVersion: v0.2\nprepare:\n steps: []\n" + response_data = mock_command_response(output=yaml_content) + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + result = await manager.get_profile("production") + + assert result == yaml_content + + @pytest.mark.asyncio + async def test_get_profile_invalid_name_raises_error( + self, mock_http_client_for_resource, mock_command_response + ): + """get_profile should raise ValueError for invalid profile names.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + with pytest.raises(ValueError, match="Invalid profile name"): + await manager.get_profile("invalid/name") + + +class TestDeleteProfile: + """Tests for the delete_profile method.""" + + @pytest.fixture + def mock_command_response(self): + """Factory to create mock command output responses.""" + + def _create(output: str = "", error: str = ""): + return { + "command": "", + "workingDir": "/home/user", + "output": output, + "error": error, + } + + return _create + + @pytest.mark.asyncio + async def test_delete_profile_calls_rm_command( + self, mock_http_client_for_resource, mock_command_response + ): + """delete_profile should execute rm command.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + await manager.delete_profile("production") + + mock_client.request.assert_awaited_once() + call_args = mock_client.request.call_args + assert call_args.kwargs.get("method") == "POST" + assert call_args.kwargs.get("endpoint") == "/workspaces/72678/execute" + + @pytest.mark.asyncio + async def test_delete_profile_invalid_name_raises_error( + self, mock_http_client_for_resource, mock_command_response + ): + """delete_profile should raise ValueError for invalid profile names.""" + response_data = mock_command_response() + mock_client = mock_http_client_for_resource(response_data) + manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) + + with pytest.raises(ValueError, match="Invalid profile name"): + await manager.delete_profile("../etc/passwd") + + +class TestProfileBuilder: + """Tests for the ProfileBuilder fluent API.""" + + def test_build_empty_profile(self): + """ProfileBuilder should create an empty profile.""" + profile = ProfileBuilder().build() + + assert isinstance(profile, ProfileConfig) + assert profile.schema_version == "v0.2" + assert len(profile.prepare.steps) == 0 + assert len(profile.test.steps) == 0 + assert len(profile.run) == 0 + + def test_build_with_prepare_steps(self): + """ProfileBuilder should add prepare stage steps.""" + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .add_step("npm run build", name="Build") + .done() + .build() + ) + + assert len(profile.prepare.steps) == 2 + assert profile.prepare.steps[0].command == "npm install" + assert profile.prepare.steps[0].name is None + assert profile.prepare.steps[1].command == "npm run build" + assert profile.prepare.steps[1].name == "Build" + + def test_build_with_test_steps(self): + """ProfileBuilder should add test stage steps.""" + profile = ProfileBuilder().test().add_step("npm test").done().build() + + assert len(profile.test.steps) == 1 + assert profile.test.steps[0].command == "npm test" + + def test_build_with_reactive_service(self): + """ProfileBuilder should add reactive services.""" + profile = ( + ProfileBuilder() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000, public=True) + .replicas(2) + .env("NODE_ENV", "production") + .done() + .build() + ) + + assert "web" in profile.run + service = profile.run["web"] + assert isinstance(service, ReactiveServiceConfig) + assert len(service.steps) == 1 + assert service.replicas == 2 + assert service.env == {"NODE_ENV": "production"} + assert service.network is not None + assert len(service.network.ports) == 1 + assert service.network.ports[0].port == 3000 + assert service.network.ports[0].is_public is True + + def test_build_with_reactive_service_paths(self): + """ProfileBuilder should add path routing to reactive services.""" + profile = ( + ProfileBuilder() + .add_reactive_service("api") + .add_port(8080) + .add_path("/api", port=8080, strip_path=True) + .add_path("/health", port=8080) + .done() + .build() + ) + + service = profile.run["api"] + assert isinstance(service, ReactiveServiceConfig) + assert len(service.network.paths) == 2 + assert service.network.paths[0].path == "/api" + assert service.network.paths[0].strip_path is True + assert service.network.paths[1].path == "/health" + + def test_build_with_reactive_service_all_options(self): + """ProfileBuilder should support all reactive service options.""" + profile = ( + ProfileBuilder() + .add_reactive_service("worker") + .add_step("python worker.py") + .plan(123) + .replicas(3) + .base_image("python:3.11") + .run_as(user=1000, group=1000) + .mount_sub_path("/data") + .health_endpoint("/health") + .envs({"KEY1": "value1", "KEY2": "value2"}) + .done() + .build() + ) + + service = profile.run["worker"] + assert isinstance(service, ReactiveServiceConfig) + assert service.plan == 123 + assert service.replicas == 3 + assert service.base_image == "python:3.11" + assert service.run_as_user == 1000 + assert service.run_as_group == 1000 + assert service.mount_sub_path == "/data" + assert service.health_endpoint == "/health" + assert service.env == {"KEY1": "value1", "KEY2": "value2"} + + def test_build_with_managed_service(self): + """ProfileBuilder should add managed services.""" + profile = ( + ProfileBuilder() + .add_managed_service("db", provider="postgres", plan="small") + .config("max_connections", 100) + .secret("password", "vault://secrets/db-password") + .done() + .build() + ) + + assert "db" in profile.run + service = profile.run["db"] + assert isinstance(service, ManagedServiceConfig) + assert service.provider == "postgres" + assert service.plan == "small" + assert service.config == {"max_connections": 100} + assert service.secrets == {"password": "vault://secrets/db-password"} + + def test_build_with_multiple_services(self): + """ProfileBuilder should support multiple services.""" + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .done() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000) + .done() + .add_reactive_service("worker") + .add_step("npm run worker") + .done() + .add_managed_service("redis", provider="redis", plan="micro") + .done() + .build() + ) + + assert len(profile.prepare.steps) == 1 + assert len(profile.run) == 3 + assert "web" in profile.run + assert "worker" in profile.run + assert "redis" in profile.run + + def test_profile_to_yaml(self): + """ProfileConfig should serialize to YAML correctly.""" + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .done() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000, public=True) + .done() + .build() + ) + + yaml_output = profile.to_yaml() + + assert "schemaVersion: v0.2" in yaml_output + assert "prepare:" in yaml_output + assert "npm install" in yaml_output + assert "run:" in yaml_output + assert "web:" in yaml_output + + +class TestReactiveServiceBuilder: + """Tests for the standalone ReactiveServiceBuilder.""" + + def test_build_reactive_service(self): + """ReactiveServiceBuilder should create a service configuration.""" + name, config = ( + ReactiveServiceBuilder("api") + .add_step("npm start") + .add_port(8080, public=True) + .replicas(2) + .build() + ) + + assert name == "api" + assert isinstance(config, ReactiveServiceConfig) + assert config.replicas == 2 + + def test_service_name_property(self): + """ReactiveServiceBuilder should expose the service name.""" + builder = ReactiveServiceBuilder("my-service") + assert builder.name == "my-service" + + +class TestManagedServiceBuilder: + """Tests for the standalone ManagedServiceBuilder.""" + + def test_build_managed_service(self): + """ManagedServiceBuilder should create a managed service configuration.""" + name, config = ( + ManagedServiceBuilder("cache", "redis", "medium") + .config("maxmemory", "256mb") + .secrets({"auth_token": "secret"}) + .build() + ) + + assert name == "cache" + assert isinstance(config, ManagedServiceConfig) + assert config.provider == "redis" + assert config.plan == "medium" + assert config.config == {"maxmemory": "256mb"} + assert config.secrets == {"auth_token": "secret"} + + +class TestProfileConfigModels: + """Tests for the profile configuration Pydantic models.""" + + def test_step_model(self): + """Step model should have command and optional name.""" + step = Step(command="echo hello") + assert step.command == "echo hello" + assert step.name is None + + step_with_name = Step(command="npm build", name="Build") + assert step_with_name.name == "Build" + + def test_port_config_validation(self): + """PortConfig should validate port range.""" + port = PortConfig(port=8080, is_public=False) + assert port.port == 8080 + + with pytest.raises(ValueError): + PortConfig(port=0) + + with pytest.raises(ValueError): + PortConfig(port=70000) + + def test_path_config_model(self): + """PathConfig model should have port, path, and optional strip_path.""" + path = PathConfig(port=3000, path="/api") + assert path.port == 3000 + assert path.path == "/api" + assert path.strip_path is None + + def test_network_config_model(self): + """NetworkConfig should contain ports and paths.""" + network = NetworkConfig( + ports=[PortConfig(port=3000, is_public=True)], + paths=[PathConfig(port=3000, path="/")], + ) + assert len(network.ports) == 1 + assert len(network.paths) == 1 + + def test_reactive_service_config_defaults(self): + """ReactiveServiceConfig should have sensible defaults.""" + config = ReactiveServiceConfig() + assert config.replicas == 1 + assert config.steps == [] + assert config.env is None + assert config.network is None + + def test_managed_service_config(self): + """ManagedServiceConfig should have provider and plan.""" + config = ManagedServiceConfig(provider="postgres", plan="large") + assert config.provider == "postgres" + assert config.plan == "large" + + def test_stage_config_defaults(self): + """StageConfig should have empty steps by default.""" + stage = StageConfig() + assert stage.steps == [] + + def test_profile_config_camel_case_serialization(self): + """ProfileConfig should serialize with camelCase keys.""" + profile = ProfileConfig() + data = profile.model_dump(by_alias=True) + + assert "schemaVersion" in data + assert "schema_version" not in data From 3a8b6f65b59985c96ad6bea256c6038249924b43 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Sat, 7 Feb 2026 18:21:49 +0100 Subject: [PATCH 2/4] add integration test --- examples/create_workspace_with_landscape.py | 72 ++++++++ .../resources/workspace/landscape/schemas.py | 15 +- src/codesphere/resources/workspace/schemas.py | 34 ++++ tests/integration/test_landscape.py | 154 ++++++++++++++++++ .../workspace/landscape/test_landscape.py | 40 ++++- 5 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 examples/create_workspace_with_landscape.py diff --git a/examples/create_workspace_with_landscape.py b/examples/create_workspace_with_landscape.py new file mode 100644 index 0000000..cff7d10 --- /dev/null +++ b/examples/create_workspace_with_landscape.py @@ -0,0 +1,72 @@ +import asyncio + +from codesphere import CodesphereSDK +from codesphere.resources.workspace import WorkspaceCreate +from codesphere.resources.workspace.landscape import ProfileBuilder + +TEAM_ID = 123 # Replace with your actual team ID + + +async def main(): + async with CodesphereSDK() as sdk: + plans = await sdk.metadata.list_plans() + plan_id = next( + (p for p in plans if p.title == "Micro" and not p.deprecated), None + ).id + + payload = WorkspaceCreate( + plan_id, + team_id=TEAM_ID, + name=f"my-unique-landscape-demo-{int(asyncio.time())}", + ) + + print("\nCreating workspace...") + workspace = await sdk.workspaces.create(payload) + print(f"Created workspace: {workspace.name} (ID: {workspace.id})") + + try: + print("Waiting for workspace to be running...") + await workspace.wait_until_running(timeout=300.0, poll_interval=5.0) + print("Workspace is now running!") + + print("\nCreating landscape profile...") + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install", name="Install dependencies") + .done() + .add_reactive_service("web") + .plan(plan_id) + .add_step("npm start") + .add_port(3000, public=True) + .add_path("/", port=3000) + .replicas(1) + .env("NODE_ENV", "production") + .done() + .build() + ) + + profile_name = "production" + await workspace.landscape.save_profile(profile_name, profile) + print(f"Saved profile: {profile_name}") + + profiles = await workspace.landscape.list_profiles() + print(f"Available profiles: {[p.name for p in profiles]}") + + yaml_content = await workspace.landscape.get_profile(profile_name) + print(f"\nGenerated profile YAML:\n{yaml_content}") + + print("\nDeploying landscape...") + await workspace.landscape.deploy(profile=profile_name) + print("Deployment started!") + + finally: + # Cleanup: Delete the workspace + # print("\nCleaning up...") + # await workspace.delete() + # print(f"Deleted workspace: {workspace.name}") + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py index 628f3c1..036d0da 100644 --- a/src/codesphere/resources/workspace/landscape/schemas.py +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -47,9 +47,9 @@ class ReactiveServiceConfig(CamelModel): """Configuration for a reactive (custom) service in the run stage.""" steps: List[Step] = Field(default_factory=list) - env: Optional[Dict[str, str]] = None - plan: Optional[int] = None + plan: int # Required - workspace plan ID replicas: int = 1 + env: Optional[Dict[str, str]] = None base_image: Optional[str] = None run_as_user: Optional[int] = Field(default=None, ge=0, le=65534) run_as_group: Optional[int] = Field(default=None, ge=0, le=65534) @@ -289,16 +289,25 @@ def build(self) -> tuple[str, ReactiveServiceConfig]: Returns: Tuple of (service_name, ReactiveServiceConfig). + + Raises: + ValueError: If plan is not set. """ + if self._plan is None: + raise ValueError( + f"Service '{self._name}' requires a plan ID. " + "Use .plan(plan_id) to set it." + ) + network = None if self._ports or self._paths: network = NetworkConfig(ports=self._ports, paths=self._paths) config = ReactiveServiceConfig( steps=self._steps, - env=self._env if self._env else None, plan=self._plan, replicas=self._replicas, + env=self._env if self._env else None, base_image=self._base_image, run_as_user=self._run_as_user, run_as_group=self._run_as_group, diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index f6bfd20..af9134d 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from functools import cached_property from typing import Dict, List, Optional @@ -96,6 +97,39 @@ async def get_status(self) -> WorkspaceStatus: return await self._execute_operation(_GET_STATUS_OP) + async def wait_until_running( + self, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + ) -> None: + """Wait until the workspace is in a running state. + + Args: + timeout: Maximum time to wait in seconds (default: 300s / 5 minutes). + poll_interval: Time between status checks in seconds (default: 5s). + + Raises: + TimeoutError: If the workspace is not running within the timeout period. + """ + elapsed = 0.0 + while elapsed < timeout: + status = await self.get_status() + if status.is_running: + log.debug(f"Workspace {self.id} is now running.") + return + + log.debug( + f"Workspace {self.id} not running yet, " + f"waiting {poll_interval}s... (elapsed: {elapsed:.1f}s)" + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Workspace {self.id} did not reach running state within {timeout} seconds." + ) + async def execute_command( self, command: str, env: Optional[Dict[str, str]] = None ) -> CommandOutput: diff --git a/tests/integration/test_landscape.py b/tests/integration/test_landscape.py index 4e462ce..c01d2e4 100644 --- a/tests/integration/test_landscape.py +++ b/tests/integration/test_landscape.py @@ -185,6 +185,7 @@ async def test_save_profile_with_builder( self, sdk_client: CodesphereSDK, test_workspace: Workspace, + test_plan_id: int, ): """save_profile should create a profile file using ProfileBuilder.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) @@ -196,6 +197,7 @@ async def test_save_profile_with_builder( .add_step("echo 'Installing dependencies'") .done() .add_reactive_service("web") + .plan(test_plan_id) .add_step("echo 'Starting server'") .add_port(3000, public=True) .replicas(1) @@ -280,6 +282,7 @@ async def test_get_profile_returns_yaml_content( self, sdk_client: CodesphereSDK, test_workspace: Workspace, + test_plan_id: int, ): """get_profile should return the YAML content of a saved profile.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) @@ -291,6 +294,7 @@ async def test_get_profile_returns_yaml_content( .add_step("npm install") .done() .add_reactive_service("api") + .plan(test_plan_id) .add_step("npm start") .add_port(8080) .env("NODE_ENV", "production") @@ -357,6 +361,7 @@ async def test_complex_profile_roundtrip( self, sdk_client: CodesphereSDK, test_workspace: Workspace, + test_plan_id: int, ): """A complex profile should survive save and retrieve.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) @@ -372,6 +377,7 @@ async def test_complex_profile_roundtrip( .add_step("npm test") .done() .add_reactive_service("frontend") + .plan(test_plan_id) .add_step("npm run serve") .add_port(3000, public=True) .add_path("/", port=3000) @@ -380,6 +386,7 @@ async def test_complex_profile_roundtrip( .health_endpoint("/health") .done() .add_reactive_service("backend") + .plan(test_plan_id) .add_step("python -m uvicorn main:app") .add_port(8000) .add_path("/api", port=8000, strip_path=True) @@ -412,6 +419,7 @@ async def test_profile_with_special_characters_in_env( self, sdk_client: CodesphereSDK, test_workspace: Workspace, + test_plan_id: int, ): """Profile with special characters in env values should work.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) @@ -420,6 +428,7 @@ async def test_profile_with_special_characters_in_env( profile = ( ProfileBuilder() .add_reactive_service("app") + .plan(test_plan_id) .add_step("npm start") .add_port(3000) .env("DATABASE_URL", "postgres://user:p@ss=word@localhost:5432/db") @@ -437,3 +446,148 @@ async def test_profile_with_special_characters_in_env( finally: await workspace.landscape.delete_profile(profile_name) + + +class TestLandscapeDeploymentWorkflow: + """Integration tests for the complete landscape deployment workflow.""" + + async def test_full_landscape_workflow_deploy_teardown_delete( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + test_plan_id: int, + ): + """Test the complete workflow: create profile, deploy, teardown, delete profile.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-workflow-test" + + # Step 1: Create a valid profile with ProfileBuilder + profile = ( + ProfileBuilder() + .prepare() + .add_step("echo 'Preparing...'", name="Prepare") + .done() + .add_reactive_service("web") + .plan(test_plan_id) + .add_step("echo 'Starting web service' && sleep infinity") + .add_port(3000, public=True) + .add_path("/", port=3000) + .replicas(1) + .env("NODE_ENV", "production") + .done() + .build() + ) + + try: + # Step 2: Save the profile + await workspace.landscape.save_profile(profile_name, profile) + + # Verify profile exists + profiles = await workspace.landscape.list_profiles() + profile_names = [p.name for p in profiles] + assert profile_name in profile_names, "Profile should exist after saving" + + # Verify profile content + content = await workspace.landscape.get_profile(profile_name) + assert "schemaVersion: v0.2" in content + assert "web:" in content + assert f"plan: {test_plan_id}" in content + + # Step 3: Deploy the landscape + await workspace.landscape.deploy(profile=profile_name) + + # Step 4: Teardown the landscape + await workspace.landscape.teardown() + + finally: + # Step 5: Delete the profile + await workspace.landscape.delete_profile(profile_name) + + # Verify profile is deleted + profiles = await workspace.landscape.list_profiles() + profile_names = [p.name for p in profiles] + assert profile_name not in profile_names, ( + "Profile should not exist after deletion" + ) + + async def test_deploy_and_teardown_only( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + test_plan_id: int, + ): + """Test deploy and teardown without profile deletion.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-deploy-teardown-test" + + profile = ( + ProfileBuilder() + .prepare() + .add_step("echo 'Setup complete'") + .done() + .add_reactive_service("api") + .plan(test_plan_id) + .add_step("echo 'API running' && sleep infinity") + .add_port(8080) + .add_path("/api", port=8080) + .replicas(1) + .done() + .build() + ) + + try: + # Save profile + await workspace.landscape.save_profile(profile_name, profile) + + # Deploy + await workspace.landscape.deploy(profile=profile_name) + + # Teardown + await workspace.landscape.teardown() + + # Profile should still exist after teardown + profiles = await workspace.landscape.list_profiles() + profile_names = [p.name for p in profiles] + assert profile_name in profile_names, ( + "Profile should still exist after teardown" + ) + + finally: + # Cleanup + await workspace.landscape.delete_profile(profile_name) + + async def test_profile_deletion_removes_from_list( + self, + sdk_client: CodesphereSDK, + test_workspace: Workspace, + test_plan_id: int, + ): + """Verify that deleting a profile removes it from the profile list.""" + workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) + profile_name = "sdk-deletion-verify-test" + + profile = ( + ProfileBuilder() + .add_reactive_service("service") + .plan(test_plan_id) + .add_step("echo 'running'") + .add_port(3000) + .done() + .build() + ) + + # Create profile + await workspace.landscape.save_profile(profile_name, profile) + + # Verify it exists + profiles_before = await workspace.landscape.list_profiles() + assert profile_name in [p.name for p in profiles_before] + + # Delete profile + await workspace.landscape.delete_profile(profile_name) + + # Verify it's gone + profiles_after = await workspace.landscape.list_profiles() + assert profile_name not in [p.name for p in profiles_after], ( + f"Profile '{profile_name}' should be removed after deletion" + ) diff --git a/tests/resources/workspace/landscape/test_landscape.py b/tests/resources/workspace/landscape/test_landscape.py index d2ed3ae..425b9ce 100644 --- a/tests/resources/workspace/landscape/test_landscape.py +++ b/tests/resources/workspace/landscape/test_landscape.py @@ -424,6 +424,9 @@ async def test_delete_profile_invalid_name_raises_error( class TestProfileBuilder: """Tests for the ProfileBuilder fluent API.""" + # Default plan ID for testing + TEST_PLAN_ID = 8 + def test_build_empty_profile(self): """ProfileBuilder should create an empty profile.""" profile = ProfileBuilder().build() @@ -463,6 +466,7 @@ def test_build_with_reactive_service(self): profile = ( ProfileBuilder() .add_reactive_service("web") + .plan(self.TEST_PLAN_ID) .add_step("npm start") .add_port(3000, public=True) .replicas(2) @@ -475,6 +479,7 @@ def test_build_with_reactive_service(self): service = profile.run["web"] assert isinstance(service, ReactiveServiceConfig) assert len(service.steps) == 1 + assert service.plan == self.TEST_PLAN_ID assert service.replicas == 2 assert service.env == {"NODE_ENV": "production"} assert service.network is not None @@ -487,6 +492,7 @@ def test_build_with_reactive_service_paths(self): profile = ( ProfileBuilder() .add_reactive_service("api") + .plan(self.TEST_PLAN_ID) .add_port(8080) .add_path("/api", port=8080, strip_path=True) .add_path("/health", port=8080) @@ -556,10 +562,12 @@ def test_build_with_multiple_services(self): .add_step("npm install") .done() .add_reactive_service("web") + .plan(self.TEST_PLAN_ID) .add_step("npm start") .add_port(3000) .done() .add_reactive_service("worker") + .plan(self.TEST_PLAN_ID) .add_step("npm run worker") .done() .add_managed_service("redis", provider="redis", plan="micro") @@ -581,6 +589,7 @@ def test_profile_to_yaml(self): .add_step("npm install") .done() .add_reactive_service("web") + .plan(self.TEST_PLAN_ID) .add_step("npm start") .add_port(3000, public=True) .done() @@ -594,15 +603,31 @@ def test_profile_to_yaml(self): assert "npm install" in yaml_output assert "run:" in yaml_output assert "web:" in yaml_output + assert f"plan: {self.TEST_PLAN_ID}" in yaml_output + + def test_build_reactive_service_without_plan_raises_error(self): + """Building a reactive service without plan should raise ValueError.""" + with pytest.raises(ValueError, match="requires a plan ID"): + ( + ProfileBuilder() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000) + .done() + .build() + ) class TestReactiveServiceBuilder: """Tests for the standalone ReactiveServiceBuilder.""" + TEST_PLAN_ID = 8 + def test_build_reactive_service(self): """ReactiveServiceBuilder should create a service configuration.""" name, config = ( ReactiveServiceBuilder("api") + .plan(self.TEST_PLAN_ID) .add_step("npm start") .add_port(8080, public=True) .replicas(2) @@ -611,6 +636,7 @@ def test_build_reactive_service(self): assert name == "api" assert isinstance(config, ReactiveServiceConfig) + assert config.plan == self.TEST_PLAN_ID assert config.replicas == 2 def test_service_name_property(self): @@ -618,6 +644,11 @@ def test_service_name_property(self): builder = ReactiveServiceBuilder("my-service") assert builder.name == "my-service" + def test_build_without_plan_raises_error(self): + """Building without plan should raise ValueError.""" + with pytest.raises(ValueError, match="requires a plan ID"): + ReactiveServiceBuilder("api").add_step("npm start").build() + class TestManagedServiceBuilder: """Tests for the standalone ManagedServiceBuilder.""" @@ -642,6 +673,8 @@ def test_build_managed_service(self): class TestProfileConfigModels: """Tests for the profile configuration Pydantic models.""" + TEST_PLAN_ID = 8 + def test_step_model(self): """Step model should have command and optional name.""" step = Step(command="echo hello") @@ -678,9 +711,10 @@ def test_network_config_model(self): assert len(network.ports) == 1 assert len(network.paths) == 1 - def test_reactive_service_config_defaults(self): - """ReactiveServiceConfig should have sensible defaults.""" - config = ReactiveServiceConfig() + def test_reactive_service_config_requires_plan(self): + """ReactiveServiceConfig should require a plan.""" + config = ReactiveServiceConfig(plan=self.TEST_PLAN_ID) + assert config.plan == self.TEST_PLAN_ID assert config.replicas == 1 assert config.steps == [] assert config.env is None From 6ed575f74121420a9003906c5447e9a1ae90ae46 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Sat, 7 Feb 2026 18:25:59 +0100 Subject: [PATCH 3/4] nits --- tests/integration/test_landscape.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/integration/test_landscape.py b/tests/integration/test_landscape.py index c01d2e4..1af8282 100644 --- a/tests/integration/test_landscape.py +++ b/tests/integration/test_landscape.py @@ -208,7 +208,6 @@ async def test_save_profile_with_builder( try: await workspace.landscape.save_profile(profile_name, profile) - # Verify profile was created profiles = await workspace.landscape.list_profiles() profile_names = [p.name for p in profiles] assert profile_name in profile_names @@ -253,12 +252,10 @@ async def test_save_profile_overwrites_existing( workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-overwrite-test" - # Create initial profile profile_v1 = ( ProfileBuilder().prepare().add_step("echo 'version 1'").done().build() ) - # Create updated profile profile_v2 = ( ProfileBuilder().prepare().add_step("echo 'version 2'").done().build() ) @@ -267,7 +264,6 @@ async def test_save_profile_overwrites_existing( await workspace.landscape.save_profile(profile_name, profile_v1) await workspace.landscape.save_profile(profile_name, profile_v2) - # Verify content was updated content = await workspace.landscape.get_profile(profile_name) assert "version 2" in content @@ -331,14 +327,11 @@ async def test_delete_profile_removes_file( profile = ProfileBuilder().build() await workspace.landscape.save_profile(profile_name, profile) - # Verify it exists profiles = await workspace.landscape.list_profiles() assert profile_name in [p.name for p in profiles] - # Delete it await workspace.landscape.delete_profile(profile_name) - # Verify it's gone profiles = await workspace.landscape.list_profiles() assert profile_name not in [p.name for p in profiles] @@ -350,7 +343,6 @@ async def test_delete_nonexistent_profile_no_error( """delete_profile should not raise an error for non-existent profiles.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) - # Should not raise await workspace.landscape.delete_profile("nonexistent-profile-xyz") @@ -403,7 +395,6 @@ async def test_complex_profile_roundtrip( content = await workspace.landscape.get_profile(profile_name) - # Verify key elements are present assert "schemaVersion: v0.2" in content assert "frontend:" in content assert "backend:" in content @@ -461,7 +452,6 @@ async def test_full_landscape_workflow_deploy_teardown_delete( workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-workflow-test" - # Step 1: Create a valid profile with ProfileBuilder profile = ( ProfileBuilder() .prepare() @@ -479,31 +469,24 @@ async def test_full_landscape_workflow_deploy_teardown_delete( ) try: - # Step 2: Save the profile await workspace.landscape.save_profile(profile_name, profile) - # Verify profile exists profiles = await workspace.landscape.list_profiles() profile_names = [p.name for p in profiles] assert profile_name in profile_names, "Profile should exist after saving" - # Verify profile content content = await workspace.landscape.get_profile(profile_name) assert "schemaVersion: v0.2" in content assert "web:" in content assert f"plan: {test_plan_id}" in content - # Step 3: Deploy the landscape await workspace.landscape.deploy(profile=profile_name) - # Step 4: Teardown the landscape await workspace.landscape.teardown() finally: - # Step 5: Delete the profile await workspace.landscape.delete_profile(profile_name) - # Verify profile is deleted profiles = await workspace.landscape.list_profiles() profile_names = [p.name for p in profiles] assert profile_name not in profile_names, ( @@ -536,16 +519,12 @@ async def test_deploy_and_teardown_only( ) try: - # Save profile await workspace.landscape.save_profile(profile_name, profile) - # Deploy await workspace.landscape.deploy(profile=profile_name) - # Teardown await workspace.landscape.teardown() - # Profile should still exist after teardown profiles = await workspace.landscape.list_profiles() profile_names = [p.name for p in profiles] assert profile_name in profile_names, ( @@ -553,7 +532,6 @@ async def test_deploy_and_teardown_only( ) finally: - # Cleanup await workspace.landscape.delete_profile(profile_name) async def test_profile_deletion_removes_from_list( @@ -576,17 +554,13 @@ async def test_profile_deletion_removes_from_list( .build() ) - # Create profile await workspace.landscape.save_profile(profile_name, profile) - # Verify it exists profiles_before = await workspace.landscape.list_profiles() assert profile_name in [p.name for p in profiles_before] - # Delete profile await workspace.landscape.delete_profile(profile_name) - # Verify it's gone profiles_after = await workspace.landscape.list_profiles() assert profile_name not in [p.name for p in profiles_after], ( f"Profile '{profile_name}' should be removed after deletion" From c9b3e618ddd711a5abcd589a892b53e76fad2e60 Mon Sep 17 00:00:00 2001 From: Datata1 <> Date: Mon, 9 Feb 2026 22:50:23 +0100 Subject: [PATCH 4/4] adress feedback --- examples/create_workspace_with_landscape.py | 94 +++---- .../resources/workspace/landscape/models.py | 127 ++------- .../resources/workspace/landscape/schemas.py | 244 ++---------------- src/codesphere/resources/workspace/schemas.py | 25 +- tests/integration/test_landscape.py | 51 +--- .../workspace/landscape/test_landscape.py | 78 ------ 6 files changed, 111 insertions(+), 508 deletions(-) diff --git a/examples/create_workspace_with_landscape.py b/examples/create_workspace_with_landscape.py index cff7d10..a85cf28 100644 --- a/examples/create_workspace_with_landscape.py +++ b/examples/create_workspace_with_landscape.py @@ -1,71 +1,61 @@ import asyncio +import time from codesphere import CodesphereSDK from codesphere.resources.workspace import WorkspaceCreate -from codesphere.resources.workspace.landscape import ProfileBuilder +from codesphere.resources.workspace.landscape import ProfileBuilder, ProfileConfig TEAM_ID = 123 # Replace with your actual team ID -async def main(): - async with CodesphereSDK() as sdk: - plans = await sdk.metadata.list_plans() - plan_id = next( - (p for p in plans if p.title == "Micro" and not p.deprecated), None - ).id - - payload = WorkspaceCreate( - plan_id, - team_id=TEAM_ID, - name=f"my-unique-landscape-demo-{int(asyncio.time())}", - ) +async def get_plan_id(sdk: CodesphereSDK, plan_name: str = "Micro") -> int: + plans = await sdk.metadata.list_plans() + plan = next((p for p in plans if p.title == plan_name and not p.deprecated), None) + if not plan: + raise ValueError(f"Plan '{plan_name}' not found") + return plan.id - print("\nCreating workspace...") - workspace = await sdk.workspaces.create(payload) - print(f"Created workspace: {workspace.name} (ID: {workspace.id})") - try: - print("Waiting for workspace to be running...") - await workspace.wait_until_running(timeout=300.0, poll_interval=5.0) - print("Workspace is now running!") +def build_web_profile(plan_id: int) -> ProfileConfig: + """Build a simple web service landscape profile.""" + return ( + ProfileBuilder() + .prepare() + .add_step("npm install", name="Install dependencies") + .done() + .add_reactive_service("web") + .plan(plan_id) + .add_step("npm start") + .add_port(3000, public=True) + .add_path("/", port=3000) + .replicas(1) + .env("NODE_ENV", "production") + .build() + ) - print("\nCreating landscape profile...") - profile = ( - ProfileBuilder() - .prepare() - .add_step("npm install", name="Install dependencies") - .done() - .add_reactive_service("web") - .plan(plan_id) - .add_step("npm start") - .add_port(3000, public=True) - .add_path("/", port=3000) - .replicas(1) - .env("NODE_ENV", "production") - .done() - .build() - ) - profile_name = "production" - await workspace.landscape.save_profile(profile_name, profile) - print(f"Saved profile: {profile_name}") +async def create_workspace(sdk: CodesphereSDK, plan_id: int, name: str): + workspace = await sdk.workspaces.create( + WorkspaceCreate(plan_id=plan_id, team_id=TEAM_ID, name=name) + ) + await workspace.wait_until_running(timeout=300.0, poll_interval=5.0) + return workspace - profiles = await workspace.landscape.list_profiles() - print(f"Available profiles: {[p.name for p in profiles]}") - yaml_content = await workspace.landscape.get_profile(profile_name) - print(f"\nGenerated profile YAML:\n{yaml_content}") +async def deploy_landscape(workspace, profile: dict, profile_name: str = "production"): + await workspace.landscape.save_profile(profile_name, profile) + await workspace.landscape.deploy(profile=profile_name) + print("Deployment started!") - print("\nDeploying landscape...") - await workspace.landscape.deploy(profile=profile_name) - print("Deployment started!") - finally: - # Cleanup: Delete the workspace - # print("\nCleaning up...") - # await workspace.delete() - # print(f"Deleted workspace: {workspace.name}") - pass +async def main(): + async with CodesphereSDK() as sdk: + plan_id = await get_plan_id(sdk) + workspace = await create_workspace( + sdk, plan_id, f"landscape-demo-{int(time.time())}" + ) + profile = build_web_profile(plan_id) + await deploy_landscape(workspace, profile) if __name__ == "__main__": diff --git a/src/codesphere/resources/workspace/landscape/models.py b/src/codesphere/resources/workspace/landscape/models.py index 4009098..8d6fc11 100644 --- a/src/codesphere/resources/workspace/landscape/models.py +++ b/src/codesphere/resources/workspace/landscape/models.py @@ -26,143 +26,72 @@ _VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") -class WorkspaceLandscapeManager(_APIOperationExecutor): - """Manager for workspace landscape operations (Multi Server Deployments).""" +def _validate_profile_name(name: str) -> None: + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + +def _profile_filename(name: str) -> str: + _validate_profile_name(name) + return f"ci.{name}.yml" + +class WorkspaceLandscapeManager(_APIOperationExecutor): def __init__(self, http_client: APIHttpClient, workspace_id: int): self._http_client = http_client self._workspace_id = workspace_id self.id = workspace_id - async def list_profiles(self) -> ResourceList[Profile]: - """List all available deployment profiles in the workspace. - - Profiles are discovered by listing files matching the pattern ci..yml - in the workspace root directory. - - Returns: - ResourceList of Profile objects. - """ + async def _run_command(self, command: str) -> "CommandOutput": from ..operations import _EXECUTE_COMMAND_OP from ..schemas import CommandInput - command_data = CommandInput(command="ls -1 *.yml 2>/dev/null || true") - result: CommandOutput = await self._execute_operation( - _EXECUTE_COMMAND_OP, data=command_data + return await self._execute_operation( + _EXECUTE_COMMAND_OP, data=CommandInput(command=command) ) + async def list_profiles(self) -> ResourceList[Profile]: + result = await self._run_command("ls -1 *.yml 2>/dev/null || true") + profiles: List[Profile] = [] if result.output: for line in result.output.strip().split("\n"): - line = line.strip() - if match := _PROFILE_FILE_PATTERN.match(line): - profile_name = match.group(1) - profiles.append(Profile(name=profile_name)) + if match := _PROFILE_FILE_PATTERN.match(line.strip()): + profiles.append(Profile(name=match.group(1))) return ResourceList[Profile](root=profiles) async def save_profile(self, name: str, config: Union[ProfileConfig, str]) -> None: - """Save a profile configuration to the workspace. - - Args: - name: Profile name (must match pattern ^[A-Za-z0-9_-]+$). - config: ProfileConfig instance or YAML string. + filename = _profile_filename(name) - Raises: - ValueError: If the profile name is invalid. - """ - from ..operations import _EXECUTE_COMMAND_OP - from ..schemas import CommandInput - - if not _VALID_PROFILE_NAME.match(name): - raise ValueError( - f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" - ) - - # Convert ProfileConfig to YAML if needed if isinstance(config, ProfileConfig): yaml_content = config.to_yaml() else: yaml_content = config - # Escape single quotes in YAML content for shell - escaped_content = yaml_content.replace("'", "'\"'\"'") - - # Write the profile file - filename = f"ci.{name}.yml" - command = f"cat > {filename} << 'PROFILE_EOF'\n{yaml_content}PROFILE_EOF" - - command_data = CommandInput(command=command) - await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) - - async def get_profile(self, name: str) -> str: - """Get the raw YAML content of a profile. - - Args: - name: Profile name. - - Returns: - YAML content of the profile as a string. - - Raises: - ValueError: If the profile name is invalid. - """ - from ..operations import _EXECUTE_COMMAND_OP - from ..schemas import CommandInput - - if not _VALID_PROFILE_NAME.match(name): - raise ValueError( - f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" - ) - - filename = f"ci.{name}.yml" - command_data = CommandInput(command=f"cat {filename}") - result: CommandOutput = await self._execute_operation( - _EXECUTE_COMMAND_OP, data=command_data + body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" + await self._run_command( + f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n" ) + async def get_profile(self, name: str) -> str: + result = await self._run_command(f"cat {_profile_filename(name)}") return result.output async def delete_profile(self, name: str) -> None: - """Delete a profile from the workspace. - - Args: - name: Profile name to delete. - - Raises: - ValueError: If the profile name is invalid. - """ - from ..operations import _EXECUTE_COMMAND_OP - from ..schemas import CommandInput - - if not _VALID_PROFILE_NAME.match(name): - raise ValueError( - f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" - ) - - filename = f"ci.{name}.yml" - command_data = CommandInput(command=f"rm -f {filename}") - await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) + await self._run_command(f"rm -f {_profile_filename(name)}") async def deploy(self, profile: Optional[str] = None) -> None: - """Deploy the landscape. - - Args: - profile: Optional deployment profile name (must match pattern ^[A-Za-z0-9-_]+$). - """ if profile is not None: + _validate_profile_name(profile) await self._execute_operation(_DEPLOY_WITH_PROFILE_OP, profile=profile) else: await self._execute_operation(_DEPLOY_OP) async def teardown(self) -> None: - """Teardown the landscape.""" await self._execute_operation(_TEARDOWN_OP) async def scale(self, services: Dict[str, int]) -> None: - """Scale landscape services. - - Args: - services: A dictionary mapping service names to replica counts (minimum 1). - """ await self._execute_operation(_SCALE_OP, data=services) diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py index 036d0da..621b719 100644 --- a/src/codesphere/resources/workspace/landscape/schemas.py +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -9,45 +9,33 @@ class Profile(BaseModel): - """Landscape deployment profile model.""" - name: str class Step(CamelModel): - """A step in a pipeline stage.""" - name: Optional[str] = None command: str class PortConfig(CamelModel): - """Port configuration for a reactive service.""" - port: int = Field(ge=1, le=65535) is_public: bool = False class PathConfig(CamelModel): - """Path routing configuration for a reactive service.""" - port: int = Field(ge=1, le=65535) path: str strip_path: Optional[bool] = None class NetworkConfig(CamelModel): - """Network configuration for a reactive service.""" - ports: List[PortConfig] = Field(default_factory=list) paths: List[PathConfig] = Field(default_factory=list) class ReactiveServiceConfig(CamelModel): - """Configuration for a reactive (custom) service in the run stage.""" - steps: List[Step] = Field(default_factory=list) - plan: int # Required - workspace plan ID + plan: int replicas: int = 1 env: Optional[Dict[str, str]] = None base_image: Optional[str] = None @@ -59,8 +47,6 @@ class ReactiveServiceConfig(CamelModel): class ManagedServiceConfig(CamelModel): - """Configuration for a managed service (e.g., database) in the run stage.""" - provider: str plan: str config: Optional[Dict[str, Any]] = None @@ -68,14 +54,10 @@ class ManagedServiceConfig(CamelModel): class StageConfig(CamelModel): - """Configuration for a pipeline stage (prepare/test).""" - steps: List[Step] = Field(default_factory=list) class ProfileConfig(CamelModel): - """Complete pipeline configuration for a landscape profile (schema v0.2).""" - schema_version: Literal["v0.2"] = Field(default="v0.2", alias="schemaVersion") prepare: StageConfig = Field(default_factory=StageConfig) test: StageConfig = Field(default_factory=StageConfig) @@ -84,75 +66,49 @@ class ProfileConfig(CamelModel): ) def to_yaml(self, *, exclude_none: bool = True) -> str: - """Export the profile configuration as YAML. - - Args: - exclude_none: Exclude fields with None values if True. - - Returns: - YAML string representation of the profile. - """ data = self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json") return yaml.safe_dump( data, default_flow_style=False, allow_unicode=True, sort_keys=False ) -# ============================================================================= -# Fluent Builder Classes -# ============================================================================= - - class StepBuilder: - """Fluent builder for pipeline steps.""" - def __init__(self, command: str, name: Optional[str] = None): self._command = command self._name = name def build(self) -> Step: - """Build the Step instance.""" return Step(command=self._command, name=self._name) class PortBuilder: - """Fluent builder for port configuration.""" - def __init__(self, port: int): self._port = port self._is_public = False def public(self, is_public: bool = True) -> PortBuilder: - """Set whether the port is publicly accessible.""" self._is_public = is_public return self def build(self) -> PortConfig: - """Build the PortConfig instance.""" return PortConfig(port=self._port, is_public=self._is_public) class PathBuilder: - """Fluent builder for path routing configuration.""" - def __init__(self, path: str, port: int): self._path = path self._port = port self._strip_path: Optional[bool] = None def strip_path(self, strip: bool = True) -> PathBuilder: - """Set whether to strip the path prefix when forwarding.""" self._strip_path = strip return self def build(self) -> PathConfig: - """Build the PathConfig instance.""" return PathConfig(port=self._port, path=self._path, strip_path=self._strip_path) class ReactiveServiceBuilder: - """Fluent builder for reactive (custom) service configuration.""" - def __init__(self, name: str): self._name = name self._steps: List[Step] = [] @@ -169,130 +125,60 @@ def __init__(self, name: str): @property def name(self) -> str: - """Get the service name.""" return self._name def add_step( self, command: str, name: Optional[str] = None ) -> ReactiveServiceBuilder: - """Add a step to the service. - - Args: - command: The command to execute. - name: Optional name for the step. - """ self._steps.append(Step(command=command, name=name)) return self def env(self, key: str, value: str) -> ReactiveServiceBuilder: - """Add an environment variable. - - Args: - key: Environment variable name. - value: Environment variable value. - """ self._env[key] = value return self def envs(self, env_vars: Dict[str, str]) -> ReactiveServiceBuilder: - """Add multiple environment variables. - - Args: - env_vars: Dictionary of environment variables. - """ self._env.update(env_vars) return self def plan(self, plan_id: int) -> ReactiveServiceBuilder: - """Set the plan ID for the service. - - Args: - plan_id: The workspace plan ID. - """ self._plan = plan_id return self def replicas(self, count: int) -> ReactiveServiceBuilder: - """Set the number of replicas. - - Args: - count: Number of replicas (minimum 1). - """ self._replicas = max(1, count) return self def base_image(self, image: str) -> ReactiveServiceBuilder: - """Set the base image for the service. - - Args: - image: Docker image reference. - """ self._base_image = image return self def run_as( self, user: Optional[int] = None, group: Optional[int] = None ) -> ReactiveServiceBuilder: - """Set the user and group to run the service as. - - Args: - user: User ID (0-65534). - group: Group ID (0-65534). - """ self._run_as_user = user self._run_as_group = group return self def mount_sub_path(self, path: str) -> ReactiveServiceBuilder: - """Set the mount sub-path. - - Args: - path: Sub-path to mount. - """ self._mount_sub_path = path return self def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilder: - """Set the health check endpoint. - - Args: - endpoint: Health check endpoint path. - """ self._health_endpoint = endpoint return self def add_port(self, port: int, *, public: bool = False) -> ReactiveServiceBuilder: - """Add a port to the service. - - Args: - port: Port number (1-65535). - public: Whether the port is publicly accessible. - """ self._ports.append(PortConfig(port=port, is_public=public)) return self def add_path( self, path: str, port: int, *, strip_path: Optional[bool] = None ) -> ReactiveServiceBuilder: - """Add a path routing rule. - - Args: - path: URL path to route. - port: Port to forward to. - strip_path: Whether to strip the path prefix. - """ self._paths.append(PathConfig(port=port, path=path, strip_path=strip_path)) return self def build(self) -> tuple[str, ReactiveServiceConfig]: - """Build the service configuration. - - Returns: - Tuple of (service_name, ReactiveServiceConfig). - - Raises: - ValueError: If plan is not set. - """ if self._plan is None: raise ValueError( f"Service '{self._name}' requires a plan ID. " @@ -319,8 +205,6 @@ def build(self) -> tuple[str, ReactiveServiceConfig]: class ManagedServiceBuilder: - """Fluent builder for managed service configuration.""" - def __init__(self, name: str, provider: str, plan: str): self._name = name self._provider = provider @@ -330,53 +214,25 @@ def __init__(self, name: str, provider: str, plan: str): @property def name(self) -> str: - """Get the service name.""" return self._name def config(self, key: str, value: Any) -> ManagedServiceBuilder: - """Add a configuration option. - - Args: - key: Configuration key. - value: Configuration value. - """ self._config[key] = value return self def configs(self, config: Dict[str, Any]) -> ManagedServiceBuilder: - """Add multiple configuration options. - - Args: - config: Dictionary of configuration options. - """ self._config.update(config) return self def secret(self, key: str, value: str) -> ManagedServiceBuilder: - """Add a secret. - - Args: - key: Secret key. - value: Secret value (or vault reference). - """ self._secrets[key] = value return self def secrets(self, secrets: Dict[str, str]) -> ManagedServiceBuilder: - """Add multiple secrets. - - Args: - secrets: Dictionary of secrets. - """ self._secrets.update(secrets) return self def build(self) -> tuple[str, ManagedServiceConfig]: - """Build the managed service configuration. - - Returns: - Tuple of (service_name, ManagedServiceConfig). - """ config = ManagedServiceConfig( provider=self._provider, plan=self._plan, @@ -419,55 +275,48 @@ def __init__(self) -> None: self._prepare_steps: List[Step] = [] self._test_steps: List[Step] = [] self._services: Dict[str, ReactiveServiceConfig | ManagedServiceConfig] = {} + self._current_service: Optional[Any] = None def prepare(self) -> PrepareStageBuilder: - """Configure the prepare stage. - - Returns: - A PrepareStageBuilder for fluent configuration. - """ return PrepareStageBuilder(self) def test(self) -> TestStageBuilder: - """Configure the test stage. - - Returns: - A TestStageBuilder for fluent configuration. - """ return TestStageBuilder(self) def add_reactive_service(self, name: str) -> ReactiveServiceBuilderContext: - """Add a reactive (custom) service to the run stage. - - Args: - name: Unique name for the service. - - Returns: - A ReactiveServiceBuilderContext for fluent configuration. - """ - return ReactiveServiceBuilderContext(self, name) + self._finalize_current_service() + self._current_service = ReactiveServiceBuilderContext(self, name) + return self._current_service def add_managed_service( self, name: str, *, provider: str, plan: str ) -> ManagedServiceBuilderContext: - """Add a managed service (e.g., database) to the run stage. + self._finalize_current_service() + self._current_service = ManagedServiceBuilderContext(self, name, provider, plan) + return self._current_service - Args: - name: Unique name for the service. - provider: Service provider (e.g., "postgres", "redis"). - plan: Service plan (e.g., "small", "medium"). + def __getattr__(self, name: str) -> Any: + """Delegate unknown methods to the current service builder.""" + if self._current_service is not None and hasattr(self._current_service, name): + method = getattr(self._current_service, name) + if callable(method): - Returns: - A ManagedServiceBuilderContext for fluent configuration. - """ - return ManagedServiceBuilderContext(self, name, provider, plan) + def wrapper(*args: Any, **kwargs: Any) -> ProfileBuilder: + method(*args, **kwargs) + return self - def build(self) -> ProfileConfig: - """Build the complete profile configuration. + return wrapper + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") + + def _finalize_current_service(self) -> None: + if self._current_service is not None: + name, config = self._current_service._builder.build() + self._services[name] = config + self._current_service = None - Returns: - A ProfileConfig instance ready to be saved. - """ + def build(self) -> ProfileConfig: + """Build the final profile configuration.""" + self._finalize_current_service() return ProfileConfig( prepare=StageConfig(steps=self._prepare_steps), test=StageConfig(steps=self._test_steps), @@ -476,50 +325,30 @@ def build(self) -> ProfileConfig: class PrepareStageBuilder: - """Builder context for the prepare stage.""" - def __init__(self, parent: ProfileBuilder): self._parent = parent def add_step(self, command: str, name: Optional[str] = None) -> PrepareStageBuilder: - """Add a step to the prepare stage. - - Args: - command: The command to execute. - name: Optional name for the step. - """ self._parent._prepare_steps.append(Step(command=command, name=name)) return self def done(self) -> ProfileBuilder: - """Return to the parent ProfileBuilder.""" return self._parent class TestStageBuilder: - """Builder context for the test stage.""" - def __init__(self, parent: ProfileBuilder): self._parent = parent def add_step(self, command: str, name: Optional[str] = None) -> TestStageBuilder: - """Add a step to the test stage. - - Args: - command: The command to execute. - name: Optional name for the step. - """ self._parent._test_steps.append(Step(command=command, name=name)) return self def done(self) -> ProfileBuilder: - """Return to the parent ProfileBuilder.""" return self._parent class ReactiveServiceBuilderContext: - """Builder context for a reactive service within a ProfileBuilder.""" - def __init__(self, parent: ProfileBuilder, name: str): self._parent = parent self._builder = ReactiveServiceBuilder(name) @@ -527,102 +356,83 @@ def __init__(self, parent: ProfileBuilder, name: str): def add_step( self, command: str, name: Optional[str] = None ) -> ReactiveServiceBuilderContext: - """Add a step to the service.""" self._builder.add_step(command, name) return self def env(self, key: str, value: str) -> ReactiveServiceBuilderContext: - """Add an environment variable.""" self._builder.env(key, value) return self def envs(self, env_vars: Dict[str, str]) -> ReactiveServiceBuilderContext: - """Add multiple environment variables.""" self._builder.envs(env_vars) return self def plan(self, plan_id: int) -> ReactiveServiceBuilderContext: - """Set the plan ID.""" self._builder.plan(plan_id) return self def replicas(self, count: int) -> ReactiveServiceBuilderContext: - """Set the number of replicas.""" self._builder.replicas(count) return self def base_image(self, image: str) -> ReactiveServiceBuilderContext: - """Set the base image.""" self._builder.base_image(image) return self def run_as( self, user: Optional[int] = None, group: Optional[int] = None ) -> ReactiveServiceBuilderContext: - """Set user and group IDs.""" self._builder.run_as(user, group) return self def mount_sub_path(self, path: str) -> ReactiveServiceBuilderContext: - """Set the mount sub-path.""" self._builder.mount_sub_path(path) return self def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilderContext: - """Set the health check endpoint.""" self._builder.health_endpoint(endpoint) return self def add_port( self, port: int, *, public: bool = False ) -> ReactiveServiceBuilderContext: - """Add a port to the service.""" self._builder.add_port(port, public=public) return self def add_path( self, path: str, port: int, *, strip_path: Optional[bool] = None ) -> ReactiveServiceBuilderContext: - """Add a path routing rule.""" self._builder.add_path(path, port, strip_path=strip_path) return self def done(self) -> ProfileBuilder: - """Finalize the service and return to the parent ProfileBuilder.""" name, config = self._builder.build() self._parent._services[name] = config return self._parent class ManagedServiceBuilderContext: - """Builder context for a managed service within a ProfileBuilder.""" - def __init__(self, parent: ProfileBuilder, name: str, provider: str, plan: str): self._parent = parent self._builder = ManagedServiceBuilder(name, provider, plan) def config(self, key: str, value: Any) -> ManagedServiceBuilderContext: - """Add a configuration option.""" self._builder.config(key, value) return self def configs(self, config: Dict[str, Any]) -> ManagedServiceBuilderContext: - """Add multiple configuration options.""" self._builder.configs(config) return self def secret(self, key: str, value: str) -> ManagedServiceBuilderContext: - """Add a secret.""" self._builder.secret(key, value) return self def secrets(self, secrets: Dict[str, str]) -> ManagedServiceBuilderContext: - """Add multiple secrets.""" self._builder.secrets(secrets) return self def done(self) -> ProfileBuilder: - """Finalize the service and return to the parent ProfileBuilder.""" name, config = self._builder.build() self._parent._services[name] = config return self._parent diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index af9134d..938bb5e 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -59,15 +59,11 @@ class WorkspaceUpdate(CamelModel): class CommandInput(CamelModel): - """Input model for command execution.""" - command: str env: Optional[Dict[str, str]] = None class CommandOutput(CamelModel): - """Output model for command execution.""" - command: str working_dir: str output: str @@ -75,8 +71,6 @@ class CommandOutput(CamelModel): class WorkspaceStatus(CamelModel): - """Status information for a workspace.""" - is_running: bool @@ -103,25 +97,20 @@ async def wait_until_running( timeout: float = 300.0, poll_interval: float = 5.0, ) -> None: - """Wait until the workspace is in a running state. + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") - Args: - timeout: Maximum time to wait in seconds (default: 300s / 5 minutes). - poll_interval: Time between status checks in seconds (default: 5s). - - Raises: - TimeoutError: If the workspace is not running within the timeout period. - """ elapsed = 0.0 while elapsed < timeout: status = await self.get_status() if status.is_running: - log.debug(f"Workspace {self.id} is now running.") + log.debug("Workspace %s is now running.", self.id) return - log.debug( - f"Workspace {self.id} not running yet, " - f"waiting {poll_interval}s... (elapsed: {elapsed:.1f}s)" + "Workspace %s not running yet, waiting %ss... (elapsed: %.1fs)", + self.id, + poll_interval, + elapsed, ) await asyncio.sleep(poll_interval) elapsed += poll_interval diff --git a/tests/integration/test_landscape.py b/tests/integration/test_landscape.py index 1af8282..0e63f4c 100644 --- a/tests/integration/test_landscape.py +++ b/tests/integration/test_landscape.py @@ -1,3 +1,5 @@ +import asyncio + import pytest from codesphere import CodesphereSDK @@ -12,14 +14,11 @@ class TestLandscapeProfilesIntegration: - """Integration tests for landscape profile listing.""" - async def test_list_profiles_returns_resource_list( self, sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """list_profiles should return a ResourceList.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profiles = await workspace.landscape.list_profiles() @@ -31,12 +30,10 @@ async def test_list_profiles_empty_workspace( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """list_profiles on a fresh workspace should return empty or existing profiles.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profiles = await workspace.landscape.list_profiles() - # Fresh workspace may have no profiles, which is valid assert isinstance(profiles, ResourceList) assert len(profiles) >= 0 @@ -45,10 +42,8 @@ async def test_list_profiles_after_creating_profile_file( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """list_profiles should find a profile after creating a ci..yml file.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) - # Create a test profile file profile_name = "sdk-test-profile" create_result = await workspace.execute_command( f"echo 'version: 1' > ci.{profile_name}.yml" @@ -60,12 +55,10 @@ async def test_list_profiles_after_creating_profile_file( profile_names = [p.name for p in profiles] assert profile_name in profile_names - # Verify the profile is a Profile instance matching_profile = next(p for p in profiles if p.name == profile_name) assert isinstance(matching_profile, Profile) finally: - # Cleanup: remove the test profile file await workspace.execute_command(f"rm -f ci.{profile_name}.yml") async def test_list_profiles_with_multiple_profile_files( @@ -76,7 +69,6 @@ async def test_list_profiles_with_multiple_profile_files( """list_profiles should find multiple profiles.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) - # Create multiple test profile files profile_names = ["test-profile-1", "test-profile-2", "test_profile_3"] for name in profile_names: await workspace.execute_command(f"echo 'version: 1' > ci.{name}.yml") @@ -91,7 +83,6 @@ async def test_list_profiles_with_multiple_profile_files( ) finally: - # Cleanup: remove all test profile files for name in profile_names: await workspace.execute_command(f"rm -f ci.{name}.yml") @@ -100,10 +91,8 @@ async def test_list_profiles_ignores_non_profile_yml_files( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """list_profiles should not include non-profile yml files.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) - # Create a profile file and a non-profile yml file await workspace.execute_command("echo 'version: 1' > ci.valid-profile.yml") await workspace.execute_command("echo 'key: value' > config.yml") await workspace.execute_command("echo 'services: []' > docker-compose.yml") @@ -113,12 +102,10 @@ async def test_list_profiles_ignores_non_profile_yml_files( profile_names = [p.name for p in profiles] assert "valid-profile" in profile_names - # These should NOT be in the list as they don't match ci..yml pattern assert "config" not in profile_names assert "docker-compose" not in profile_names finally: - # Cleanup await workspace.execute_command( "rm -f ci.valid-profile.yml config.yml docker-compose.yml" ) @@ -128,20 +115,16 @@ async def test_list_profiles_iterable( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """list_profiles result should be iterable.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) - # Create a test profile await workspace.execute_command("echo 'version: 1' > ci.iter-test.yml") try: profiles = await workspace.landscape.list_profiles() - # Test iteration profile_list = list(profiles) assert isinstance(profile_list, list) - # Test indexing if len(profiles) > 0: first_profile = profiles[0] assert isinstance(first_profile, Profile) @@ -151,14 +134,11 @@ async def test_list_profiles_iterable( class TestLandscapeManagerAccess: - """Integration tests for accessing the landscape manager.""" - async def test_workspace_has_landscape_property( self, sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """Workspace should have a landscape property.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) assert hasattr(workspace, "landscape") @@ -169,7 +149,6 @@ async def test_landscape_manager_is_cached( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """Landscape manager should be cached on the workspace instance.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) manager1 = workspace.landscape @@ -179,15 +158,12 @@ async def test_landscape_manager_is_cached( class TestSaveProfileIntegration: - """Integration tests for saving landscape profiles.""" - async def test_save_profile_with_builder( self, sdk_client: CodesphereSDK, test_workspace: Workspace, test_plan_id: int, ): - """save_profile should create a profile file using ProfileBuilder.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-builder-test" @@ -220,7 +196,6 @@ async def test_save_profile_with_yaml_string( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """save_profile should accept a raw YAML string.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-yaml-test" @@ -248,7 +223,6 @@ async def test_save_profile_overwrites_existing( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """save_profile should overwrite an existing profile.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-overwrite-test" @@ -272,15 +246,12 @@ async def test_save_profile_overwrites_existing( class TestGetProfileIntegration: - """Integration tests for getting landscape profile content.""" - async def test_get_profile_returns_yaml_content( self, sdk_client: CodesphereSDK, test_workspace: Workspace, test_plan_id: int, ): - """get_profile should return the YAML content of a saved profile.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-get-test" @@ -313,14 +284,11 @@ async def test_get_profile_returns_yaml_content( class TestDeleteProfileIntegration: - """Integration tests for deleting landscape profiles.""" - async def test_delete_profile_removes_file( self, sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """delete_profile should remove the profile file.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-delete-test" @@ -340,22 +308,18 @@ async def test_delete_nonexistent_profile_no_error( sdk_client: CodesphereSDK, test_workspace: Workspace, ): - """delete_profile should not raise an error for non-existent profiles.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) await workspace.landscape.delete_profile("nonexistent-profile-xyz") class TestProfileBuilderIntegration: - """Integration tests for ProfileBuilder with real workspaces.""" - async def test_complex_profile_roundtrip( self, sdk_client: CodesphereSDK, test_workspace: Workspace, test_plan_id: int, ): - """A complex profile should survive save and retrieve.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-complex-test" @@ -412,7 +376,6 @@ async def test_profile_with_special_characters_in_env( test_workspace: Workspace, test_plan_id: int, ): - """Profile with special characters in env values should work.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-special-chars-test" @@ -440,15 +403,12 @@ async def test_profile_with_special_characters_in_env( class TestLandscapeDeploymentWorkflow: - """Integration tests for the complete landscape deployment workflow.""" - async def test_full_landscape_workflow_deploy_teardown_delete( self, sdk_client: CodesphereSDK, test_workspace: Workspace, test_plan_id: int, ): - """Test the complete workflow: create profile, deploy, teardown, delete profile.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-workflow-test" @@ -499,7 +459,6 @@ async def test_deploy_and_teardown_only( test_workspace: Workspace, test_plan_id: int, ): - """Test deploy and teardown without profile deletion.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-deploy-teardown-test" @@ -521,6 +480,11 @@ async def test_deploy_and_teardown_only( try: await workspace.landscape.save_profile(profile_name, profile) + # got mutex errors when deploy and teardown were called in quick succession, + # adding delay to mitigate + # maybe report a bug if this continues to be an issue + await asyncio.sleep(2) + await workspace.landscape.deploy(profile=profile_name) await workspace.landscape.teardown() @@ -540,7 +504,6 @@ async def test_profile_deletion_removes_from_list( test_workspace: Workspace, test_plan_id: int, ): - """Verify that deleting a profile removes it from the profile list.""" workspace = await sdk_client.workspaces.get(workspace_id=test_workspace.id) profile_name = "sdk-deletion-verify-test" diff --git a/tests/resources/workspace/landscape/test_landscape.py b/tests/resources/workspace/landscape/test_landscape.py index 425b9ce..314a247 100644 --- a/tests/resources/workspace/landscape/test_landscape.py +++ b/tests/resources/workspace/landscape/test_landscape.py @@ -19,18 +19,14 @@ class TestWorkspaceLandscapeManager: - """Tests for the WorkspaceLandscapeManager class.""" - @pytest.fixture def landscape_manager(self, mock_http_client_for_resource): - """Create a WorkspaceLandscapeManager with mock HTTP client.""" mock_client = mock_http_client_for_resource(None) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) return manager, mock_client @pytest.mark.asyncio async def test_deploy_without_profile(self, landscape_manager): - """Deploy without profile should call the basic deploy endpoint.""" manager, mock_client = landscape_manager await manager.deploy() @@ -42,7 +38,6 @@ async def test_deploy_without_profile(self, landscape_manager): @pytest.mark.asyncio async def test_deploy_with_profile(self, mock_http_client_for_resource): - """Deploy with profile should call the profile-specific endpoint.""" mock_client = mock_http_client_for_resource(None) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -58,7 +53,6 @@ async def test_deploy_with_profile(self, mock_http_client_for_resource): @pytest.mark.asyncio async def test_teardown(self, landscape_manager): - """Teardown should call the teardown endpoint.""" manager, mock_client = landscape_manager await manager.teardown() @@ -72,7 +66,6 @@ async def test_teardown(self, landscape_manager): @pytest.mark.asyncio async def test_scale_services(self, mock_http_client_for_resource): - """Scale should call the scale endpoint with service configuration.""" mock_client = mock_http_client_for_resource(None) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -87,7 +80,6 @@ async def test_scale_services(self, mock_http_client_for_resource): @pytest.mark.asyncio async def test_scale_single_service(self, mock_http_client_for_resource): - """Scale should work with a single service.""" mock_client = mock_http_client_for_resource(None) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -100,12 +92,8 @@ async def test_scale_single_service(self, mock_http_client_for_resource): class TestListProfiles: - """Tests for the list_profiles method.""" - @pytest.fixture def mock_command_response(self): - """Factory to create mock command output responses.""" - def _create(output: str, error: str = ""): return { "command": "ls -1 *.yml 2>/dev/null || true", @@ -120,7 +108,6 @@ def _create(output: str, error: str = ""): async def test_list_profiles_with_multiple_profiles( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should return profiles from ci..yml files.""" response_data = mock_command_response( "ci.production.yml\nci.staging.yml\nci.dev-test.yml\n" ) @@ -139,7 +126,6 @@ async def test_list_profiles_with_multiple_profiles( async def test_list_profiles_with_single_profile( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should work with a single profile.""" response_data = mock_command_response("ci.main.yml\n") mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -153,7 +139,6 @@ async def test_list_profiles_with_single_profile( async def test_list_profiles_with_no_profiles( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should return empty list when no profiles exist.""" response_data = mock_command_response("") mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -167,7 +152,6 @@ async def test_list_profiles_with_no_profiles( async def test_list_profiles_filters_non_profile_yml_files( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should filter out non-profile yml files.""" response_data = mock_command_response( "ci.production.yml\nconfig.yml\ndocker-compose.yml\nci.staging.yml\n" ) @@ -185,7 +169,6 @@ async def test_list_profiles_filters_non_profile_yml_files( async def test_list_profiles_with_underscore_in_name( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should handle underscores in profile names.""" response_data = mock_command_response("ci.my_profile.yml\n") mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -199,7 +182,6 @@ async def test_list_profiles_with_underscore_in_name( async def test_list_profiles_calls_execute_endpoint( self, mock_http_client_for_resource, mock_command_response ): - """list_profiles should call the execute command endpoint.""" response_data = mock_command_response("") mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -213,22 +195,17 @@ async def test_list_profiles_calls_execute_endpoint( class TestProfileModel: - """Tests for the Profile model.""" - def test_create_profile(self): - """Profile should be created with a name.""" profile = Profile(name="production") assert profile.name == "production" def test_profile_from_dict(self): - """Profile should be created from dictionary.""" profile = Profile.model_validate({"name": "staging"}) assert profile.name == "staging" def test_profile_dump(self): - """Profile should dump to dictionary correctly.""" profile = Profile(name="dev") dumped = profile.model_dump() @@ -236,11 +213,8 @@ def test_profile_dump(self): class TestWorkspaceLandscapeManagerAccess: - """Tests for accessing the landscape manager from a Workspace instance.""" - @pytest.mark.asyncio async def test_workspace_landscape_property(self, workspace_model_factory): - """Workspace should expose a landscape property.""" workspace, _ = workspace_model_factory() landscape_manager = workspace.landscape @@ -249,7 +223,6 @@ async def test_workspace_landscape_property(self, workspace_model_factory): @pytest.mark.asyncio async def test_workspace_landscape_is_cached(self, workspace_model_factory): - """Landscape manager should be cached on the workspace.""" workspace, _ = workspace_model_factory() manager1 = workspace.landscape @@ -259,12 +232,8 @@ async def test_workspace_landscape_is_cached(self, workspace_model_factory): class TestSaveProfile: - """Tests for the save_profile method.""" - @pytest.fixture def mock_command_response(self): - """Factory to create mock command output responses.""" - def _create(output: str = "", error: str = ""): return { "command": "", @@ -279,7 +248,6 @@ def _create(output: str = "", error: str = ""): async def test_save_profile_with_profile_config( self, mock_http_client_for_resource, mock_command_response ): - """save_profile should write a ProfileConfig to a file.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -296,7 +264,6 @@ async def test_save_profile_with_profile_config( async def test_save_profile_with_yaml_string( self, mock_http_client_for_resource, mock_command_response ): - """save_profile should accept a raw YAML string.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -310,7 +277,6 @@ async def test_save_profile_with_yaml_string( async def test_save_profile_invalid_name_raises_error( self, mock_http_client_for_resource, mock_command_response ): - """save_profile should raise ValueError for invalid profile names.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -322,7 +288,6 @@ async def test_save_profile_invalid_name_raises_error( async def test_save_profile_invalid_name_with_spaces( self, mock_http_client_for_resource, mock_command_response ): - """save_profile should reject names with spaces.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -332,12 +297,8 @@ async def test_save_profile_invalid_name_with_spaces( class TestGetProfile: - """Tests for the get_profile method.""" - @pytest.fixture def mock_command_response(self): - """Factory to create mock command output responses.""" - def _create(output: str = "", error: str = ""): return { "command": "", @@ -352,7 +313,6 @@ def _create(output: str = "", error: str = ""): async def test_get_profile_returns_yaml_content( self, mock_http_client_for_resource, mock_command_response ): - """get_profile should return the YAML content of a profile.""" yaml_content = "schemaVersion: v0.2\nprepare:\n steps: []\n" response_data = mock_command_response(output=yaml_content) mock_client = mock_http_client_for_resource(response_data) @@ -366,7 +326,6 @@ async def test_get_profile_returns_yaml_content( async def test_get_profile_invalid_name_raises_error( self, mock_http_client_for_resource, mock_command_response ): - """get_profile should raise ValueError for invalid profile names.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -376,12 +335,8 @@ async def test_get_profile_invalid_name_raises_error( class TestDeleteProfile: - """Tests for the delete_profile method.""" - @pytest.fixture def mock_command_response(self): - """Factory to create mock command output responses.""" - def _create(output: str = "", error: str = ""): return { "command": "", @@ -396,7 +351,6 @@ def _create(output: str = "", error: str = ""): async def test_delete_profile_calls_rm_command( self, mock_http_client_for_resource, mock_command_response ): - """delete_profile should execute rm command.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -412,7 +366,6 @@ async def test_delete_profile_calls_rm_command( async def test_delete_profile_invalid_name_raises_error( self, mock_http_client_for_resource, mock_command_response ): - """delete_profile should raise ValueError for invalid profile names.""" response_data = mock_command_response() mock_client = mock_http_client_for_resource(response_data) manager = WorkspaceLandscapeManager(http_client=mock_client, workspace_id=72678) @@ -422,13 +375,9 @@ async def test_delete_profile_invalid_name_raises_error( class TestProfileBuilder: - """Tests for the ProfileBuilder fluent API.""" - - # Default plan ID for testing TEST_PLAN_ID = 8 def test_build_empty_profile(self): - """ProfileBuilder should create an empty profile.""" profile = ProfileBuilder().build() assert isinstance(profile, ProfileConfig) @@ -438,7 +387,6 @@ def test_build_empty_profile(self): assert len(profile.run) == 0 def test_build_with_prepare_steps(self): - """ProfileBuilder should add prepare stage steps.""" profile = ( ProfileBuilder() .prepare() @@ -455,14 +403,12 @@ def test_build_with_prepare_steps(self): assert profile.prepare.steps[1].name == "Build" def test_build_with_test_steps(self): - """ProfileBuilder should add test stage steps.""" profile = ProfileBuilder().test().add_step("npm test").done().build() assert len(profile.test.steps) == 1 assert profile.test.steps[0].command == "npm test" def test_build_with_reactive_service(self): - """ProfileBuilder should add reactive services.""" profile = ( ProfileBuilder() .add_reactive_service("web") @@ -488,7 +434,6 @@ def test_build_with_reactive_service(self): assert service.network.ports[0].is_public is True def test_build_with_reactive_service_paths(self): - """ProfileBuilder should add path routing to reactive services.""" profile = ( ProfileBuilder() .add_reactive_service("api") @@ -508,7 +453,6 @@ def test_build_with_reactive_service_paths(self): assert service.network.paths[1].path == "/health" def test_build_with_reactive_service_all_options(self): - """ProfileBuilder should support all reactive service options.""" profile = ( ProfileBuilder() .add_reactive_service("worker") @@ -536,7 +480,6 @@ def test_build_with_reactive_service_all_options(self): assert service.env == {"KEY1": "value1", "KEY2": "value2"} def test_build_with_managed_service(self): - """ProfileBuilder should add managed services.""" profile = ( ProfileBuilder() .add_managed_service("db", provider="postgres", plan="small") @@ -555,7 +498,6 @@ def test_build_with_managed_service(self): assert service.secrets == {"password": "vault://secrets/db-password"} def test_build_with_multiple_services(self): - """ProfileBuilder should support multiple services.""" profile = ( ProfileBuilder() .prepare() @@ -582,7 +524,6 @@ def test_build_with_multiple_services(self): assert "redis" in profile.run def test_profile_to_yaml(self): - """ProfileConfig should serialize to YAML correctly.""" profile = ( ProfileBuilder() .prepare() @@ -606,7 +547,6 @@ def test_profile_to_yaml(self): assert f"plan: {self.TEST_PLAN_ID}" in yaml_output def test_build_reactive_service_without_plan_raises_error(self): - """Building a reactive service without plan should raise ValueError.""" with pytest.raises(ValueError, match="requires a plan ID"): ( ProfileBuilder() @@ -619,12 +559,9 @@ def test_build_reactive_service_without_plan_raises_error(self): class TestReactiveServiceBuilder: - """Tests for the standalone ReactiveServiceBuilder.""" - TEST_PLAN_ID = 8 def test_build_reactive_service(self): - """ReactiveServiceBuilder should create a service configuration.""" name, config = ( ReactiveServiceBuilder("api") .plan(self.TEST_PLAN_ID) @@ -640,21 +577,16 @@ def test_build_reactive_service(self): assert config.replicas == 2 def test_service_name_property(self): - """ReactiveServiceBuilder should expose the service name.""" builder = ReactiveServiceBuilder("my-service") assert builder.name == "my-service" def test_build_without_plan_raises_error(self): - """Building without plan should raise ValueError.""" with pytest.raises(ValueError, match="requires a plan ID"): ReactiveServiceBuilder("api").add_step("npm start").build() class TestManagedServiceBuilder: - """Tests for the standalone ManagedServiceBuilder.""" - def test_build_managed_service(self): - """ManagedServiceBuilder should create a managed service configuration.""" name, config = ( ManagedServiceBuilder("cache", "redis", "medium") .config("maxmemory", "256mb") @@ -671,12 +603,9 @@ def test_build_managed_service(self): class TestProfileConfigModels: - """Tests for the profile configuration Pydantic models.""" - TEST_PLAN_ID = 8 def test_step_model(self): - """Step model should have command and optional name.""" step = Step(command="echo hello") assert step.command == "echo hello" assert step.name is None @@ -685,7 +614,6 @@ def test_step_model(self): assert step_with_name.name == "Build" def test_port_config_validation(self): - """PortConfig should validate port range.""" port = PortConfig(port=8080, is_public=False) assert port.port == 8080 @@ -696,14 +624,12 @@ def test_port_config_validation(self): PortConfig(port=70000) def test_path_config_model(self): - """PathConfig model should have port, path, and optional strip_path.""" path = PathConfig(port=3000, path="/api") assert path.port == 3000 assert path.path == "/api" assert path.strip_path is None def test_network_config_model(self): - """NetworkConfig should contain ports and paths.""" network = NetworkConfig( ports=[PortConfig(port=3000, is_public=True)], paths=[PathConfig(port=3000, path="/")], @@ -712,7 +638,6 @@ def test_network_config_model(self): assert len(network.paths) == 1 def test_reactive_service_config_requires_plan(self): - """ReactiveServiceConfig should require a plan.""" config = ReactiveServiceConfig(plan=self.TEST_PLAN_ID) assert config.plan == self.TEST_PLAN_ID assert config.replicas == 1 @@ -721,18 +646,15 @@ def test_reactive_service_config_requires_plan(self): assert config.network is None def test_managed_service_config(self): - """ManagedServiceConfig should have provider and plan.""" config = ManagedServiceConfig(provider="postgres", plan="large") assert config.provider == "postgres" assert config.plan == "large" def test_stage_config_defaults(self): - """StageConfig should have empty steps by default.""" stage = StageConfig() assert stage.steps == [] def test_profile_config_camel_case_serialization(self): - """ProfileConfig should serialize with camelCase keys.""" profile = ProfileConfig() data = profile.model_dump(by_alias=True)