From 96fdc5494d3c74f2de100d46469a2a8f2f750a2b Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 14 Mar 2025 15:31:03 +0200 Subject: [PATCH 01/24] WIP --- datacrunch/containers/__init__.py | 0 datacrunch/containers/containers.py | 502 ++++++++++++++++++++++++++++ datacrunch/datacrunch.py | 5 + examples/container_deployments.py | 205 ++++++++++++ 4 files changed, 712 insertions(+) create mode 100644 datacrunch/containers/__init__.py create mode 100644 datacrunch/containers/containers.py create mode 100644 examples/container_deployments.py diff --git a/datacrunch/containers/__init__.py b/datacrunch/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py new file mode 100644 index 0000000..28b6980 --- /dev/null +++ b/datacrunch/containers/containers.py @@ -0,0 +1,502 @@ +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json, config, Undefined # type: ignore +from typing import List, Optional, Dict +from datetime import datetime +from marshmallow import fields + + +# API endpoints +CONTAINER_DEPLOYMENTS_ENDPOINT = '/container-deployments' +SERVERLESS_COMPUTE_RESOURCES_ENDPOINT = '/serverless-compute-resources' +SECRETS_ENDPOINT = '/secrets' +CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT = '/container-registry-credentials' + + +@dataclass_json +@dataclass +class HealthcheckSettings: + enabled: bool + port: Optional[int] = None + path: Optional[str] = None + + +@dataclass_json +@dataclass +class EntrypointOverridesSettings: + enabled: bool + entrypoint: Optional[List[str]] = None + cmd: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class EnvVar: + name: str + value_or_reference_to_secret: str + type: str # "plain" or "secret" + + +@dataclass_json +@dataclass +class AutoupdateSettings: + enabled: bool + mode: Optional[str] = None # "latest" or "semantic" + tag_filter: Optional[str] = None + + +@dataclass_json +@dataclass +class VolumeMount: + type: str # "scratch" or "secret" + mount_path: str + + +@dataclass_json +@dataclass +class Container: + name: str + image: str + exposed_port: int + healthcheck: Optional[HealthcheckSettings] = None + entrypoint_overrides: Optional[EntrypointOverridesSettings] = None + env: Optional[List[EnvVar]] = None + autoupdate: Optional[AutoupdateSettings] = None + volume_mounts: Optional[List[VolumeMount]] = None + + +@dataclass_json +@dataclass +class ContainerRegistryCredentials: + name: str + + +@dataclass_json +@dataclass +class ContainerRegistrySettings: + is_private: bool + credentials: Optional[ContainerRegistryCredentials] = None + + +@dataclass_json +@dataclass +class ComputeResource: + name: str + size: int + # Made optional since it's only used in API responses + is_available: Optional[bool] = None + + +@dataclass_json +@dataclass +class ScalingPolicy: + delay_seconds: int + + +@dataclass_json +@dataclass +class QueueLoadScalingTrigger: + threshold: float + + +@dataclass_json +@dataclass +class UtilizationScalingTrigger: + enabled: bool + threshold: Optional[float] = None + + +@dataclass_json +@dataclass +class ScalingTriggers: + queue_load: Optional[QueueLoadScalingTrigger] = None + cpu_utilization: Optional[UtilizationScalingTrigger] = None + gpu_utilization: Optional[UtilizationScalingTrigger] = None + + +@dataclass_json +@dataclass +class ScalingOptions: + min_replica_count: int + max_replica_count: int + scale_down_policy: ScalingPolicy + scale_up_policy: ScalingPolicy + queue_message_ttl_seconds: int + concurrent_requests_per_replica: int + scaling_triggers: ScalingTriggers + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class Deployment: + name: str + container_registry_settings: ContainerRegistrySettings + containers: List[Container] + compute: ComputeResource + is_spot: bool = False + endpoint_base_url: Optional[str] = None + scaling: Optional[ScalingOptions] = None + created_at: Optional[datetime] = field( + default=None, + metadata=config( + encoder=lambda x: x.isoformat() if x is not None else None, + decoder=lambda x: datetime.fromisoformat( + x) if x is not None else None, + mm_field=fields.DateTime(format='iso') + ) + ) + + +@dataclass_json +@dataclass +class ReplicaInfo: + id: str + status: str + started_at: datetime = field( + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format='iso') + ) + ) + + +@dataclass_json +@dataclass +class Secret: + """A secret model class""" + name: str + created_at: datetime = field( + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format='iso') + ) + ) + + +@dataclass_json +@dataclass +class RegistryCredential: + """A container registry credential model class""" + name: str + created_at: datetime = field( + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format='iso') + ) + ) + + +class ContainersService: + """Service for managing container deployments""" + + def __init__(self, http_client) -> None: + """Initialize the containers service + + :param http_client: HTTP client for making API requests + :type http_client: Any + """ + self.client = http_client + + def get(self) -> List[Deployment]: + """Get all deployments + + :return: list of deployments + :rtype: List[Deployment] + """ + response = self.client.get(CONTAINER_DEPLOYMENTS_ENDPOINT) + return [Deployment.from_dict(deployment, infer_missing=True) for deployment in response.json()] + + def get_by_name(self, deployment_name: str) -> Deployment: + """Get a deployment by name + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: deployment + :rtype: Deployment + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}") + return Deployment.from_dict(response.json(), infer_missing=True) + + def create( + self, + deployment: Deployment + ) -> Deployment: + """Create a new deployment + + :param deployment: deployment configuration + :type deployment: Deployment + :return: created deployment + :rtype: Deployment + """ + response = self.client.post( + CONTAINER_DEPLOYMENTS_ENDPOINT, + deployment.to_dict() + ) + return Deployment.from_dict(response.json(), infer_missing=True) + + def update(self, deployment_name: str, deployment: Deployment) -> Deployment: + """Update an existing deployment + + :param deployment_name: name of the deployment to update + :type deployment_name: str + :param deployment: updated deployment + :type deployment: Deployment + :return: updated deployment + :rtype: Deployment + """ + response = self.client.patch( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}", + deployment.to_dict() + ) + return Deployment.from_dict(response.json(), infer_missing=True) + + def delete(self, deployment_name: str) -> None: + """Delete a deployment + + :param deployment_name: name of the deployment to delete + :type deployment_name: str + """ + self.client.delete( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}") + + def get_status(self, deployment_name: str) -> Dict: + """Get deployment status + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: deployment status + :rtype: Dict + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/status") + return response.json() + + def restart(self, deployment_name: str) -> None: + """Restart a deployment + + :param deployment_name: name of the deployment to restart + :type deployment_name: str + """ + self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/restart") + + def get_scaling_options(self, deployment_name: str) -> Dict: + """Get deployment scaling options + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: scaling options + :rtype: Dict + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling") + return response.json() + + def update_scaling_options(self, deployment_name: str, scaling_options: Dict) -> Dict: + """Update deployment scaling options + + :param deployment_name: name of the deployment + :type deployment_name: str + :param scaling_options: new scaling options + :type scaling_options: Dict + :return: updated scaling options + :rtype: Dict + """ + response = self.client.patch( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling", + scaling_options + ) + return response.json() + + def get_replicas(self, deployment_name: str) -> Dict: + """Get deployment replicas + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: replicas information + :rtype: Dict + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/replicas") + return response.json() + + def purge_queue(self, deployment_name: str) -> None: + """Purge deployment queue + + :param deployment_name: name of the deployment + :type deployment_name: str + """ + self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/purge-queue") + + def pause(self, deployment_name: str) -> None: + """Pause a deployment + + :param deployment_name: name of the deployment to pause + :type deployment_name: str + """ + self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/pause") + + def resume(self, deployment_name: str) -> None: + """Resume a deployment + + :param deployment_name: name of the deployment to resume + :type deployment_name: str + """ + self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/resume") + + def get_environment_variables(self, deployment_name: str) -> Dict: + """Get deployment environment variables + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: environment variables + :rtype: Dict + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables") + return response.json() + + def add_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + """Add environment variables to a container + + :param deployment_name: name of the deployment + :type deployment_name: str + :param container_name: name of the container + :type container_name: str + :param env_vars: environment variables to add + :type env_vars: List[Dict] + :return: updated environment variables + :rtype: Dict + """ + response = self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": env_vars} + ) + return response.json() + + def update_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + """Update environment variables of a container + + :param deployment_name: name of the deployment + :type deployment_name: str + :param container_name: name of the container + :type container_name: str + :param env_vars: updated environment variables + :type env_vars: List[Dict] + :return: updated environment variables + :rtype: Dict + """ + response = self.client.patch( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": env_vars} + ) + return response.json() + + def delete_environment_variables(self, deployment_name: str, container_name: str, env_var_names: List[str]) -> Dict: + """Delete environment variables from a container + + :param deployment_name: name of the deployment + :type deployment_name: str + :param container_name: name of the container + :type container_name: str + :param env_var_names: names of environment variables to delete + :type env_var_names: List[str] + :return: remaining environment variables + :rtype: Dict + """ + response = self.client.delete( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": env_var_names} + ) + return response.json() + + def get_compute_resources(self) -> List[ComputeResource]: + """Get available compute resources + + :return: list of compute resources + :rtype: List[ComputeResource] + """ + response = self.client.get(SERVERLESS_COMPUTE_RESOURCES_ENDPOINT) + return [ComputeResource.from_dict(resource) for resource in response.json()] + + def get_secrets(self) -> List[Secret]: + """Get all secrets + + :return: list of secrets + :rtype: List[Secret] + """ + response = self.client.get(SECRETS_ENDPOINT) + return [Secret.from_dict(secret) for secret in response.json()] + + def create_secret(self, name: str, value: str) -> Secret: + """Create a new secret + + :param name: name of the secret + :type name: str + :param value: value of the secret + :type value: str + :return: created secret + :rtype: Secret + """ + response = self.client.post( + SECRETS_ENDPOINT, {"name": name, "value": value}) + return Secret.from_dict(response.json()) + + def delete_secret(self, secret_name: str, force: bool = False) -> None: + """Delete a secret + + :param secret_name: name of the secret to delete + :type secret_name: str + :param force: force delete even if secret is in use + :type force: bool + """ + self.client.delete( + f"{SECRETS_ENDPOINT}/{secret_name}", params={"force": force}) + + def get_registry_credentials(self) -> List[RegistryCredential]: + """Get all registry credentials + + :return: list of registry credentials + :rtype: List[RegistryCredential] + """ + response = self.client.get(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT) + return [RegistryCredential.from_dict(credential) for credential in response.json()] + + def add_registry_credentials(self, name: str, registry_type: str, username: str, access_token: str) -> RegistryCredential: + """Add registry credentials + + :param name: name of the credentials + :type name: str + :param registry_type: type of registry (e.g. "dockerhub") + :type registry_type: str + :param username: registry username + :type username: str + :param access_token: registry access token + :type access_token: str + :return: created registry credential + :rtype: RegistryCredential + """ + data = { + "name": name, + "registry_type": registry_type, + "username": username, + "access_token": access_token + } + response = self.client.post( + CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, data) + return RegistryCredential.from_dict(response.json()) + + def delete_registry_credentials(self, credentials_name: str) -> None: + """Delete registry credentials + + :param credentials_name: name of the credentials to delete + :type credentials_name: str + """ + self.client.delete( + f"{CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT}/{credentials_name}") diff --git a/datacrunch/datacrunch.py b/datacrunch/datacrunch.py index ec35d55..2f5f98b 100644 --- a/datacrunch/datacrunch.py +++ b/datacrunch/datacrunch.py @@ -8,6 +8,7 @@ from datacrunch.startup_scripts.startup_scripts import StartupScriptsService from datacrunch.volume_types.volume_types import VolumeTypesService from datacrunch.volumes.volumes import VolumesService +from datacrunch.containers.containers import ContainersService from datacrunch.constants import Constants from datacrunch.locations.locations import LocationsService from datacrunch.__version__ import VERSION @@ -67,3 +68,7 @@ def __init__(self, client_id: str, client_secret: str, base_url: str = "https:// self.locations: LocationsService = LocationsService( self._http_client) """Locations service. Get locations""" + + self.containers: ContainersService = ContainersService( + self._http_client) + """Containers service. Deploy, manage, and monitor container deployments""" diff --git a/examples/container_deployments.py b/examples/container_deployments.py new file mode 100644 index 0000000..b4cb53e --- /dev/null +++ b/examples/container_deployments.py @@ -0,0 +1,205 @@ +"""Example script demonstrating container deployment management using the DataCrunch API. + +This script provides a comprehensive example of container deployment lifecycle, +including creation, monitoring, scaling, and cleanup. +""" + +import os +import time +from typing import Optional + +from datacrunch import DataCrunchClient +from datacrunch.exceptions import APIException +from datacrunch.containers.containers import ( + Container, + ComputeResource, + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, + HealthcheckSettings, + VolumeMount, + ContainerRegistrySettings, + Deployment, +) + +# Configuration constants +DEPLOYMENT_NAME = "my-deployment" +CONTAINER_NAME = "my-app" + + +def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 10, delay: int = 30) -> bool: + """Wait for deployment to reach healthy status. + + Args: + client: DataCrunch API client + deployment_name: Name of the deployment to check + max_attempts: Maximum number of status checks + delay: Delay between checks in seconds + + Returns: + bool: True if deployment is healthy, False otherwise + """ + for attempt in range(max_attempts): + try: + status = client.containers.get_status(deployment_name) + print(f"Deployment status: {status['status']}") + if status['status'] == 'healthy': + return True + time.sleep(delay) + except APIException as e: + print(f"Error checking deployment status: {e}") + return False + return False + + +def cleanup_resources(client: DataCrunchClient) -> None: + """Clean up all created resources. + + Args: + client: DataCrunch API client + """ + try: + # Delete deployment + client.containers.delete(DEPLOYMENT_NAME) + print("Deployment deleted") + except APIException as e: + print(f"Error during cleanup: {e}") + + +def main() -> None: + """Main function demonstrating deployment lifecycle management.""" + try: + # Initialize client + client_id = os.environ.get('DATACRUNCH_CLIENT_ID') + client_secret = os.environ.get('DATACRUNCH_CLIENT_SECRET') + datacrunch = DataCrunchClient(client_id, client_secret) + + # Create container configuration + container = Container( + name=CONTAINER_NAME, + image='nginx:latest', + exposed_port=80, + healthcheck=HealthcheckSettings( + enabled=True, + port=80, + path="/health" + ), + volume_mounts=[ + VolumeMount( + type="scratch", + mount_path="/data" + ) + ] + ) + + # Create scaling configuration + scaling_options = ScalingOptions( + min_replica_count=1, + max_replica_count=5, + scale_down_policy=ScalingPolicy(delay_seconds=300), + scale_up_policy=ScalingPolicy(delay_seconds=300), + queue_message_ttl_seconds=500, + concurrent_requests_per_replica=1, + scaling_triggers=ScalingTriggers( + queue_load=QueueLoadScalingTrigger(threshold=1), + cpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=80 + ), + gpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=80 + ) + ) + ) + + # Create registry and compute settings + registry_settings = ContainerRegistrySettings(is_private=False) + compute = ComputeResource(name="General Compute", size=1) + + # Create deployment object + deployment = Deployment( + name=DEPLOYMENT_NAME, + container_registry_settings=registry_settings, + containers=[container], + compute=compute, + scaling=scaling_options, + is_spot=False + ) + + # Create the deployment + created_deployment = datacrunch.containers.create(deployment) + print(f"Created deployment: {created_deployment.name}") + + # Wait for deployment to be healthy + if not wait_for_deployment_health(datacrunch, DEPLOYMENT_NAME): + print("Deployment health check failed") + cleanup_resources(datacrunch) + return + + # Update scaling configuration + try: + deployment = datacrunch.containers.get_by_name(DEPLOYMENT_NAME) + # Create new scaling options with increased replica counts + deployment.scaling = ScalingOptions( + min_replica_count=2, + max_replica_count=10, + scale_down_policy=ScalingPolicy(delay_seconds=300), + scale_up_policy=ScalingPolicy(delay_seconds=300), + queue_message_ttl_seconds=500, + concurrent_requests_per_replica=1, + scaling_triggers=ScalingTriggers( + queue_load=QueueLoadScalingTrigger(threshold=1), + cpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=80 + ), + gpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=80 + ) + ) + ) + updated_deployment = datacrunch.containers.update( + DEPLOYMENT_NAME, deployment) + print(f"Updated deployment scaling: {updated_deployment.name}") + except APIException as e: + print(f"Error updating scaling options: {e}") + + # Demonstrate deployment operations + try: + # Pause deployment + datacrunch.containers.pause(DEPLOYMENT_NAME) + print("Deployment paused") + time.sleep(60) + + # Resume deployment + datacrunch.containers.resume(DEPLOYMENT_NAME) + print("Deployment resumed") + + # Restart deployment + datacrunch.containers.restart(DEPLOYMENT_NAME) + print("Deployment restarted") + + # Purge queue + datacrunch.containers.purge_queue(DEPLOYMENT_NAME) + print("Queue purged") + except APIException as e: + print(f"Error in deployment operations: {e}") + + # Clean up + cleanup_resources(datacrunch) + + except Exception as e: + print(f"Unexpected error: {e}") + # Attempt cleanup even if there was an error + try: + cleanup_resources(datacrunch) + except Exception as cleanup_error: + print(f"Error during cleanup after failure: {cleanup_error}") + + +if __name__ == "__main__": + main() From 7ecc1741235753d0adc6ae8d3620d33e6bcc05df Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 07:51:35 +0200 Subject: [PATCH 02/24] added enums, removed autoupdater --- datacrunch/containers/containers.py | 57 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 28b6980..9e12a22 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -3,6 +3,7 @@ from typing import List, Optional, Dict from datetime import datetime from marshmallow import fields +from enum import Enum # API endpoints @@ -12,6 +13,35 @@ CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT = '/container-registry-credentials' +class EnvVarType(str, Enum): + PLAIN = "plain" + SECRET = "secret" + + +class VolumeMountType(str, Enum): + SCRATCH = "scratch" + SECRET = "secret" + + +class ContainerRegistryType(str, Enum): + GCR = "gcr" + DOCKERHUB = "dockerhub" + GITHUB = "ghcr" + AWS_ECR = "aws-ecr" + CUSTOM = "custom" + + +class ContainerDeploymentStatus(str, Enum): + INITIALIZING = "initializing" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + PAUSED = "paused" + QUOTA_REACHED = "quota_reached" + IMAGE_PULLING = "image_pulling" + VERSION_UPDATING = "version_updating" + + @dataclass_json @dataclass class HealthcheckSettings: @@ -33,21 +63,13 @@ class EntrypointOverridesSettings: class EnvVar: name: str value_or_reference_to_secret: str - type: str # "plain" or "secret" - - -@dataclass_json -@dataclass -class AutoupdateSettings: - enabled: bool - mode: Optional[str] = None # "latest" or "semantic" - tag_filter: Optional[str] = None + type: EnvVarType # "plain" or "secret" @dataclass_json @dataclass class VolumeMount: - type: str # "scratch" or "secret" + type: VolumeMountType # "scratch" or "secret" mount_path: str @@ -60,7 +82,6 @@ class Container: healthcheck: Optional[HealthcheckSettings] = None entrypoint_overrides: Optional[EntrypointOverridesSettings] = None env: Optional[List[EnvVar]] = None - autoupdate: Optional[AutoupdateSettings] = None volume_mounts: Optional[List[VolumeMount]] = None @@ -262,17 +283,17 @@ def delete(self, deployment_name: str) -> None: self.client.delete( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}") - def get_status(self, deployment_name: str) -> Dict: + def get_status(self, deployment_name: str) -> ContainerDeploymentStatus: """Get deployment status :param deployment_name: name of the deployment :type deployment_name: str :return: deployment status - :rtype: Dict + :rtype: ContainerDeploymentStatus """ response = self.client.get( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/status") - return response.json() + return ContainerDeploymentStatus(response.json()["status"]) def restart(self, deployment_name: str) -> None: """Restart a deployment @@ -468,13 +489,13 @@ def get_registry_credentials(self) -> List[RegistryCredential]: response = self.client.get(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT) return [RegistryCredential.from_dict(credential) for credential in response.json()] - def add_registry_credentials(self, name: str, registry_type: str, username: str, access_token: str) -> RegistryCredential: + def add_registry_credentials(self, name: str, registry_type: ContainerRegistryType, username: str, access_token: str) -> RegistryCredential: """Add registry credentials :param name: name of the credentials :type name: str - :param registry_type: type of registry (e.g. "dockerhub") - :type registry_type: str + :param registry_type: type of registry (e.g. ContainerRegistryType.DOCKERHUB) + :type registry_type: ContainerRegistryType :param username: registry username :type username: str :param access_token: registry access token @@ -484,7 +505,7 @@ def add_registry_credentials(self, name: str, registry_type: str, username: str, """ data = { "name": name, - "registry_type": registry_type, + "registry_type": registry_type.value, "username": username, "access_token": access_token } From b96e592755197f97168976463479225c9452724b Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 13:12:31 +0200 Subject: [PATCH 03/24] added sglang deployment example --- .../{ => containers}/container_deployments.py | 9 +- examples/containers/sglang_deployment.py | 321 ++++++++++++++++++ 2 files changed, 326 insertions(+), 4 deletions(-) rename examples/{ => containers}/container_deployments.py (96%) create mode 100644 examples/containers/sglang_deployment.py diff --git a/examples/container_deployments.py b/examples/containers/container_deployments.py similarity index 96% rename from examples/container_deployments.py rename to examples/containers/container_deployments.py index b4cb53e..8271d2f 100644 --- a/examples/container_deployments.py +++ b/examples/containers/container_deployments.py @@ -6,7 +6,6 @@ import os import time -from typing import Optional from datacrunch import DataCrunchClient from datacrunch.exceptions import APIException @@ -22,6 +21,8 @@ VolumeMount, ContainerRegistrySettings, Deployment, + VolumeMountType, + ContainerDeploymentStatus, ) # Configuration constants @@ -44,8 +45,8 @@ def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, m for attempt in range(max_attempts): try: status = client.containers.get_status(deployment_name) - print(f"Deployment status: {status['status']}") - if status['status'] == 'healthy': + print(f"Deployment status: {status}") + if status == ContainerDeploymentStatus.HEALTHY: return True time.sleep(delay) except APIException as e: @@ -88,7 +89,7 @@ def main() -> None: ), volume_mounts=[ VolumeMount( - type="scratch", + type=VolumeMountType.SCRATCH, mount_path="/data" ) ] diff --git a/examples/containers/sglang_deployment.py b/examples/containers/sglang_deployment.py new file mode 100644 index 0000000..92fc8f1 --- /dev/null +++ b/examples/containers/sglang_deployment.py @@ -0,0 +1,321 @@ +"""Example script demonstrating SGLang model deployment using the DataCrunch API. + +This script provides an example of deploying a SGLang server with deepseek-ai/deepseek-llm-7b-chat model, +including creation, monitoring, testing, and cleanup. +""" + +import os +import time +import signal +import sys +import requests + +from datacrunch import DataCrunchClient +from datacrunch.exceptions import APIException +from datacrunch.containers.containers import ( + Container, + ComputeResource, + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, + HealthcheckSettings, + EntrypointOverridesSettings, + EnvVar, + EnvVarType, + ContainerRegistrySettings, + Deployment, + ContainerDeploymentStatus, +) + +# Configuration constants +DEPLOYMENT_NAME = "sglang-deployment-tutorial" +CONTAINER_NAME = "sglang-server" +MODEL_PATH = "deepseek-ai/deepseek-llm-7b-chat" +HF_SECRET_NAME = "huggingface-token" +IMAGE_URL = "docker.io/lmsysorg/sglang:v0.4.1.post6-cu124" + +# Environment variables +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') +HF_TOKEN = os.environ.get('HF_TOKEN') +INFERENCE_API_KEY = os.environ.get('INFERENCE_API_KEY') +CONTAINERS_API_URL = f'https://containers.datacrunch.io/{DEPLOYMENT_NAME}' + +# DataCrunch client instance (global for graceful shutdown) +datacrunch = None + + +def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 20, delay: int = 30) -> bool: + """Wait for deployment to reach healthy status. + + Args: + client: DataCrunch API client + deployment_name: Name of the deployment to check + max_attempts: Maximum number of status checks + delay: Delay between checks in seconds + + Returns: + bool: True if deployment is healthy, False otherwise + """ + print(f"Waiting for deployment to be healthy (may take several minutes to download model)...") + for attempt in range(max_attempts): + try: + status = client.containers.get_status(deployment_name) + print( + f"Attempt {attempt+1}/{max_attempts} - Deployment status: {status}") + if status == ContainerDeploymentStatus.HEALTHY: + return True + time.sleep(delay) + except APIException as e: + print(f"Error checking deployment status: {e}") + return False + return False + + +def cleanup_resources(client: DataCrunchClient) -> None: + """Clean up all created resources. + + Args: + client: DataCrunchAPI client + """ + try: + # Delete deployment + client.containers.delete(DEPLOYMENT_NAME) + print("Deployment deleted") + except APIException as e: + print(f"Error during cleanup: {e}") + + +def graceful_shutdown(signum, frame) -> None: + """Handle graceful shutdown on signals.""" + print(f"\nSignal {signum} received, cleaning up resources...") + try: + cleanup_resources(datacrunch) + except Exception as e: + print(f"Error during cleanup: {e}") + sys.exit(0) + + +def test_deployment(base_url: str, api_key: str) -> None: + """Test the deployment with a simple request. + + Args: + base_url: The base URL of the deployment + api_key: The API key for authentication + """ + # First, check if the model info endpoint is working + model_info_url = f"{base_url}/get_model_info" + headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + try: + print("\nTesting /get_model_info endpoint...") + response = requests.get(model_info_url, headers=headers) + if response.status_code == 200: + print("Model info endpoint is working!") + print(f"Response: {response.json()}") + else: + print(f"Request failed with status code {response.status_code}") + print(f"Response: {response.text}") + return + + # Now test completions endpoint + print("\nTesting completions API with streaming...") + completions_url = f"{base_url}/v1/completions" + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}', + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + + data = { + "model": MODEL_PATH, + "prompt": "Solar wind is a curious phenomenon. Tell me more about it", + "max_tokens": 128, + "temperature": 0.7, + "top_p": 0.9, + "stream": True + } + + with requests.post(completions_url, headers=headers, json=data, stream=True) as response: + if response.status_code == 200: + print("Stream started. Receiving first 5 events...\n") + for i, line in enumerate(response.iter_lines(decode_unicode=True)): + if line: + print(line) + if i >= 4: # Only show first 5 events + print("...(response continues)...") + break + else: + print( + f"Request failed with status code {response.status_code}") + print(f"Response: {response.text}") + + except requests.RequestException as e: + print(f"An error occurred: {e}") + + +def main() -> None: + """Main function demonstrating SGLang deployment.""" + try: + # Check required environment variables + if not DATACRUNCH_CLIENT_ID or not DATACRUNCH_CLIENT_SECRET: + print( + "Please set DATACRUNCH_CLIENT_ID and DATACRUNCH_CLIENT_SECRET environment variables") + return + + if not HF_TOKEN: + print("Please set HF_TOKEN environment variable with your Hugging Face token") + return + + # Initialize client + global datacrunch + datacrunch = DataCrunchClient( + DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) + + # Register signal handlers for cleanup + signal.signal(signal.SIGINT, graceful_shutdown) + signal.signal(signal.SIGTERM, graceful_shutdown) + + # Create a secret for the Hugging Face token + print(f"Creating secret for Hugging Face token: {HF_SECRET_NAME}") + try: + # Check if secret already exists + existing_secrets = datacrunch.containers.get_secrets() + secret_exists = any( + secret.name == HF_SECRET_NAME for secret in existing_secrets) + + if not secret_exists: + datacrunch.containers.create_secret( + HF_SECRET_NAME, HF_TOKEN) + print(f"Secret '{HF_SECRET_NAME}' created successfully") + else: + print( + f"Secret '{HF_SECRET_NAME}' already exists, using existing secret") + except APIException as e: + print(f"Error creating secret: {e}") + return + + # Create container configuration + container = Container( + name=CONTAINER_NAME, + image=IMAGE_URL, + exposed_port=30000, + healthcheck=HealthcheckSettings( + enabled=True, + port=30000, + path="/health" + ), + entrypoint_overrides=EntrypointOverridesSettings( + enabled=True, + cmd=["python3", "-m", "sglang.launch_server", "--model-path", + MODEL_PATH, "--host", "0.0.0.0", "--port", "30000"] + ), + env=[ + EnvVar( + name="HF_TOKEN", + value_or_reference_to_secret=HF_SECRET_NAME, + type=EnvVarType.SECRET + ) + ] + ) + + # Create scaling configuration - default values + scaling_options = ScalingOptions( + min_replica_count=1, + max_replica_count=2, + scale_down_policy=ScalingPolicy(delay_seconds=300), + scale_up_policy=ScalingPolicy(delay_seconds=300), + queue_message_ttl_seconds=500, + concurrent_requests_per_replica=1, + scaling_triggers=ScalingTriggers( + queue_load=QueueLoadScalingTrigger(threshold=1), + cpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=90 + ), + gpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=90 + ) + ) + ) + + # Create registry and compute settings + registry_settings = ContainerRegistrySettings(is_private=False) + # For a 7B model, General Compute (24GB VRAM) is sufficient + compute = ComputeResource(name="General Compute", size=1) + + # Create deployment object + deployment = Deployment( + name=DEPLOYMENT_NAME, + container_registry_settings=registry_settings, + containers=[container], + compute=compute, + scaling=scaling_options, + is_spot=False + ) + + # Create the deployment + created_deployment = datacrunch.containers.create(deployment) + print(f"Created deployment: {created_deployment.name}") + print("This will take several minutes while the model is downloaded and the server starts...") + + # Wait for deployment to be healthy + if not wait_for_deployment_health(datacrunch, DEPLOYMENT_NAME): + print("Deployment health check failed") + cleanup_resources(datacrunch) + return + + # Get the deployment endpoint URL and inference API key + containers_api_url = CONTAINERS_API_URL + inference_api_key = INFERENCE_API_KEY + + # If not provided as environment variables, prompt the user + if not containers_api_url: + containers_api_url = input( + "Enter your Containers API URL from the DataCrunch dashboard: ") + else: + print( + f"Using Containers API URL from environment: {containers_api_url}") + + if not inference_api_key: + inference_api_key = input( + "Enter your Inference API Key from the DataCrunch dashboard: ") + else: + print("Using Inference API Key from environment") + + # Test the deployment + if containers_api_url and inference_api_key: + print("\nTesting the deployment...") + test_deployment(containers_api_url, inference_api_key) + + # Cleanup or keep running based on user input + keep_running = input( + "\nDo you want to keep the deployment running? (y/n): ") + if keep_running.lower() != 'y': + cleanup_resources(datacrunch) + else: + print( + f"Deployment {DEPLOYMENT_NAME} is running. Don't forget to delete it when finished.") + print("You can delete it from the DataCrunch dashboard or by running:") + print(f"datacrunch.containers.delete('{DEPLOYMENT_NAME}')") + + except Exception as e: + print(f"Unexpected error: {e}") + # Attempt cleanup even if there was an error + try: + cleanup_resources(datacrunch) + except Exception as cleanup_error: + print(f"Error during cleanup after failure: {cleanup_error}") + + +if __name__ == "__main__": + main() From b8f4c08aa5b1e84216a2106dfe2df07965a87f2a Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 14:41:14 +0200 Subject: [PATCH 04/24] refactor, add patch to http client, add update scaling example --- datacrunch/containers/containers.py | 16 +-- datacrunch/http_client/http_client.py | 30 +++++ examples/containers/container_deployments.py | 22 ++- .../containers/update_deployment_scaling.py | 126 ++++++++++++++++++ requirements.txt | 11 ++ 5 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 examples/containers/update_deployment_scaling.py create mode 100644 requirements.txt diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 9e12a22..3de405c 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -304,33 +304,33 @@ def restart(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/restart") - def get_scaling_options(self, deployment_name: str) -> Dict: + def get_scaling_options(self, deployment_name: str) -> ScalingOptions: """Get deployment scaling options :param deployment_name: name of the deployment :type deployment_name: str :return: scaling options - :rtype: Dict + :rtype: ScalingOptions """ response = self.client.get( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling") - return response.json() + return ScalingOptions.from_dict(response.json()) - def update_scaling_options(self, deployment_name: str, scaling_options: Dict) -> Dict: + def update_scaling_options(self, deployment_name: str, scaling_options: ScalingOptions) -> ScalingOptions: """Update deployment scaling options :param deployment_name: name of the deployment :type deployment_name: str :param scaling_options: new scaling options - :type scaling_options: Dict + :type scaling_options: ScalingOptions :return: updated scaling options - :rtype: Dict + :rtype: ScalingOptions """ response = self.client.patch( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling", - scaling_options + scaling_options.to_dict() ) - return response.json() + return ScalingOptions.from_dict(response.json()) def get_replicas(self, deployment_name: str) -> Dict: """Get deployment replicas diff --git a/datacrunch/http_client/http_client.py b/datacrunch/http_client/http_client.py index 2ba1ac5..1375569 100644 --- a/datacrunch/http_client/http_client.py +++ b/datacrunch/http_client/http_client.py @@ -119,6 +119,36 @@ def get(self, url: str, params: dict = None, **kwargs) -> requests.Response: return response + def patch(self, url: str, json: dict = None, params: dict = None, **kwargs) -> requests.Response: + """Sends a PATCH request. + + A wrapper for the requests.patch method. + + Builds the url, uses custom headers, refresh tokens if needed. + + :param url: relative url of the API endpoint + :type url: str + :param json: A JSON serializable Python object to send in the body of the Request, defaults to None + :type json: dict, optional + :param params: Dictionary of querystring data to attach to the Request, defaults to None + :type params: dict, optional + + :raises APIException: an api exception with message and error type code + + :return: Response object + :rtype: requests.Response + """ + self._refresh_token_if_expired() + + url = self._add_base_url(url) + headers = self._generate_headers() + + response = requests.patch( + url, json=json, headers=headers, params=params, **kwargs) + handle_error(response) + + return response + def delete(self, url: str, json: dict = None, params: dict = None, **kwargs) -> requests.Response: """Sends a DELETE request. diff --git a/examples/containers/container_deployments.py b/examples/containers/container_deployments.py index 8271d2f..994cf7b 100644 --- a/examples/containers/container_deployments.py +++ b/examples/containers/container_deployments.py @@ -28,6 +28,14 @@ # Configuration constants DEPLOYMENT_NAME = "my-deployment" CONTAINER_NAME = "my-app" +IMAGE_NAME = "your-image-name:version" + +# Environment variables +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') + +# DataCrunch client instance +datacrunch = None def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 10, delay: int = 30) -> bool: @@ -72,15 +80,21 @@ def cleanup_resources(client: DataCrunchClient) -> None: def main() -> None: """Main function demonstrating deployment lifecycle management.""" try: + # Check required environment variables + if not DATACRUNCH_CLIENT_ID or not DATACRUNCH_CLIENT_SECRET: + print( + "Please set DATACRUNCH_CLIENT_ID and DATACRUNCH_CLIENT_SECRET environment variables") + return + # Initialize client - client_id = os.environ.get('DATACRUNCH_CLIENT_ID') - client_secret = os.environ.get('DATACRUNCH_CLIENT_SECRET') - datacrunch = DataCrunchClient(client_id, client_secret) + global datacrunch + datacrunch = DataCrunchClient( + DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) # Create container configuration container = Container( name=CONTAINER_NAME, - image='nginx:latest', + image=IMAGE_NAME, exposed_port=80, healthcheck=HealthcheckSettings( enabled=True, diff --git a/examples/containers/update_deployment_scaling.py b/examples/containers/update_deployment_scaling.py new file mode 100644 index 0000000..ac8f0e1 --- /dev/null +++ b/examples/containers/update_deployment_scaling.py @@ -0,0 +1,126 @@ +"""Example script demonstrating how to update scaling options for a container deployment. + +This script shows how to update scaling configurations for an existing container deployment on DataCrunch. +""" + +import os + +from datacrunch import DataCrunchClient +from datacrunch.exceptions import APIException +from datacrunch.containers.containers import ( + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger +) + +# Configuration - replace with your deployment name +DEPLOYMENT_NAME = "my-deployment" + +# Environment variables +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') + + +def check_deployment_exists(client: DataCrunchClient, deployment_name: str) -> bool: + """Check if a deployment exists. + + Args: + client: DataCrunch API client + deployment_name: Name of the deployment to check + + Returns: + bool: True if deployment exists, False otherwise + """ + try: + client.containers.get_by_name(deployment_name) + return True + except APIException as e: + print(f"Error: {e}") + return False + + +def update_deployment_scaling(client: DataCrunchClient, deployment_name: str) -> None: + """Update scaling options using the dedicated scaling options API. + + Args: + client: DataCrunch API client + deployment_name: Name of the deployment to update + """ + try: + # Create scaling options using ScalingOptions dataclass + scaling_options = ScalingOptions( + min_replica_count=1, + max_replica_count=5, + scale_down_policy=ScalingPolicy( + delay_seconds=600), # Longer cooldown period + scale_up_policy=ScalingPolicy(delay_seconds=60), # Quick scale-up + queue_message_ttl_seconds=500, + concurrent_requests_per_replica=1, + scaling_triggers=ScalingTriggers( + queue_load=QueueLoadScalingTrigger(threshold=1.0), + cpu_utilization=UtilizationScalingTrigger( + enabled=True, + threshold=75 + ), + gpu_utilization=UtilizationScalingTrigger( + enabled=False # Disable GPU utilization trigger + ) + ) + ) + + # Update scaling options + updated_options = client.containers.update_scaling_options( + deployment_name, scaling_options) + print(f"Updated deployment scaling options") + print(f"New min replicas: {updated_options.min_replica_count}") + print(f"New max replicas: {updated_options.max_replica_count}") + print( + f"CPU utilization trigger enabled: {updated_options.scaling_triggers.cpu_utilization.enabled}") + print( + f"CPU utilization threshold: {updated_options.scaling_triggers.cpu_utilization.threshold}%") + except APIException as e: + print(f"Error updating scaling options: {e}") + + +def main() -> None: + """Main function demonstrating scaling updates.""" + try: + # Check required environment variables + if not DATACRUNCH_CLIENT_ID or not DATACRUNCH_CLIENT_SECRET: + print( + "Please set DATACRUNCH_CLIENT_ID and DATACRUNCH_CLIENT_SECRET environment variables") + return + + # Initialize client + client = DataCrunchClient( + DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) + + # Verify deployment exists + if not check_deployment_exists(client, DEPLOYMENT_NAME): + print(f"Deployment {DEPLOYMENT_NAME} does not exist.") + return + + # Update scaling options using the API + update_deployment_scaling(client, DEPLOYMENT_NAME) + + # Get current scaling options + scaling_options = client.containers.get_scaling_options( + DEPLOYMENT_NAME) + print(f"\nCurrent scaling configuration:") + print(f"Min replicas: {scaling_options.min_replica_count}") + print(f"Max replicas: {scaling_options.max_replica_count}") + print( + f"Scale-up delay: {scaling_options.scale_up_policy.delay_seconds} seconds") + print( + f"Scale-down delay: {scaling_options.scale_down_policy.delay_seconds} seconds") + + print("\nScaling update completed successfully.") + + except Exception as e: + print(f"Unexpected error: {e}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c92aa13 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +certifi==2025.1.31 +charset-normalizer==3.4.1 +dataclasses-json==0.6.7 +idna==3.10 +marshmallow==3.26.1 +mypy-extensions==1.0.0 +packaging==24.2 +requests==2.32.3 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +urllib3==2.3.0 From d74e0c21afefedaa23c65141043ec1cf47473e31 Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 14:59:01 +0200 Subject: [PATCH 05/24] try to use the requirements file for action --- .github/workflows/unit_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e5205ae..cdaf609 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -24,6 +24,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + python -m pip install -r requirements.txt python -m pip install pytest pytest-cov pytest-responses responses python-dotenv - name: Test with pytest and coverage From 71f2628f41e7cf770682eb1886b9a38cf927c9e9 Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 15:30:05 +0200 Subject: [PATCH 06/24] small refactor, create registry credentials example --- datacrunch/containers/containers.py | 67 +++++++++++++--- .../registry_credentials_example.py | 78 +++++++++++++++++++ 2 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 examples/containers/registry_credentials_example.py diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 3de405c..a71c449 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -489,29 +489,74 @@ def get_registry_credentials(self) -> List[RegistryCredential]: response = self.client.get(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT) return [RegistryCredential.from_dict(credential) for credential in response.json()] - def add_registry_credentials(self, name: str, registry_type: ContainerRegistryType, username: str, access_token: str) -> RegistryCredential: + def add_registry_credentials( + self, + name: str, + registry_type: ContainerRegistryType, + username: str = None, + access_token: str = None, + service_account_key: str = None, + docker_config_json: str = None, + access_key_id: str = None, + secret_access_key: str = None, + region: str = None, + ecr_repo: str = None + ) -> None: """Add registry credentials :param name: name of the credentials :type name: str :param registry_type: type of registry (e.g. ContainerRegistryType.DOCKERHUB) :type registry_type: ContainerRegistryType - :param username: registry username + :param username: registry username (required for DOCKERHUB and GITHUB) :type username: str - :param access_token: registry access token + :param access_token: registry access token (required for DOCKERHUB and GITHUB) :type access_token: str - :return: created registry credential - :rtype: RegistryCredential + :param service_account_key: service account key JSON string (required for GCR) + :type service_account_key: str + :param docker_config_json: docker config JSON string (required for CUSTOM) + :type docker_config_json: str + :param access_key_id: AWS access key ID (required for AWS_ECR) + :type access_key_id: str + :param secret_access_key: AWS secret access key (required for AWS_ECR) + :type secret_access_key: str + :param region: AWS region (required for AWS_ECR) + :type region: str + :param ecr_repo: ECR repository URL (required for AWS_ECR) + :type ecr_repo: str """ data = { "name": name, - "registry_type": registry_type.value, - "username": username, - "access_token": access_token + "type": registry_type.value } - response = self.client.post( - CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, data) - return RegistryCredential.from_dict(response.json()) + + # Add specific parameters based on registry type + if registry_type == ContainerRegistryType.DOCKERHUB or registry_type == ContainerRegistryType.GITHUB: + if not username or not access_token: + raise ValueError( + f"Username and access_token are required for {registry_type.value} registry type") + data["username"] = username + data["access_token"] = access_token + elif registry_type == ContainerRegistryType.GCR: + if not service_account_key: + raise ValueError( + "service_account_key is required for GCR registry type") + data["service_account_key"] = service_account_key + elif registry_type == ContainerRegistryType.AWS_ECR: + if not all([access_key_id, secret_access_key, region, ecr_repo]): + raise ValueError( + "access_key_id, secret_access_key, region, and ecr_repo are required for AWS_ECR registry type") + data["access_key_id"] = access_key_id + data["secret_access_key"] = secret_access_key + data["region"] = region + data["ecr_repo"] = ecr_repo + elif registry_type == ContainerRegistryType.CUSTOM: + if not docker_config_json: + raise ValueError( + "docker_config_json is required for CUSTOM registry type") + data["docker_config_json"] = docker_config_json + + self.client.post(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, data) def delete_registry_credentials(self, credentials_name: str) -> None: """Delete registry credentials diff --git a/examples/containers/registry_credentials_example.py b/examples/containers/registry_credentials_example.py new file mode 100644 index 0000000..df10446 --- /dev/null +++ b/examples/containers/registry_credentials_example.py @@ -0,0 +1,78 @@ +import os +from datacrunch import DataCrunchClient +from datacrunch.containers.containers import ContainerRegistryType + +# Environment variables +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') + +# Initialize DataCrunch client +client = DataCrunchClient(client_id=DATACRUNCH_CLIENT_ID, + client_secret=DATACRUNCH_CLIENT_SECRET) + +# Example 1: DockerHub Credentials +client.containers.add_registry_credentials( + name="my-dockerhub-creds", + registry_type=ContainerRegistryType.DOCKERHUB, + username="your-dockerhub-username", + access_token="your-dockerhub-access-token" +) +print("Created DockerHub credentials") + +# Example 2: GitHub Container Registry Credentials +client.containers.add_registry_credentials( + name="my-github-creds", + registry_type=ContainerRegistryType.GITHUB, + username="your-github-username", + access_token="your-github-token" +) +print("Created GitHub credentials") + +# Example 3: Google Container Registry (GCR) Credentials +# For GCR, you need to provide a service account key JSON string +gcr_service_account_key = """{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "private-key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nYOUR_PRIVATE_KEY_HERE\\n-----END PRIVATE KEY-----\\n", + "client_email": "your-service-account@your-project.iam.gserviceaccount.com", + "client_id": "client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project.iam.gserviceaccount.com" +}""" + +client.containers.add_registry_credentials( + name="my-gcr-creds", + registry_type=ContainerRegistryType.GCR, + service_account_key=gcr_service_account_key +) +print("Created GCR credentials") + +# Example 4: AWS ECR Credentials +client.containers.add_registry_credentials( + name="my-aws-ecr-creds", + registry_type=ContainerRegistryType.AWS_ECR, + access_key_id="your-aws-access-key-id", + secret_access_key="your-aws-secret-access-key", + region="us-west-2", + ecr_repo="123456789012.dkr.ecr.us-west-2.amazonaws.com" +) +print("Created AWS ECR credentials") + +# Example 5: Custom Registry Credentials +custom_docker_config = """{ + "auths": { + "your-custom-registry.com": { + "auth": "base64-encoded-username-password" + } + } +}""" + +client.containers.add_registry_credentials( + name="my-custom-registry-creds", + registry_type=ContainerRegistryType.CUSTOM, + docker_config_json=custom_docker_config +) +print("Created Custom registry credentials") From a36c1a7a6862ca0c82e08320e11fcc9ebbae2360 Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 15:48:08 +0200 Subject: [PATCH 07/24] add generated test file and hope for the best --- tests/unit_tests/containers/__init__.py | 1 + .../unit_tests/containers/test_containers.py | 820 ++++++++++++++++++ 2 files changed, 821 insertions(+) create mode 100644 tests/unit_tests/containers/__init__.py create mode 100644 tests/unit_tests/containers/test_containers.py diff --git a/tests/unit_tests/containers/__init__.py b/tests/unit_tests/containers/__init__.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/tests/unit_tests/containers/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py new file mode 100644 index 0000000..55b28de --- /dev/null +++ b/tests/unit_tests/containers/test_containers.py @@ -0,0 +1,820 @@ +import pytest +import responses # https://github.com/getsentry/responses + +from datacrunch.containers.containers import ( + CONTAINER_DEPLOYMENTS_ENDPOINT, + CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, + SECRETS_ENDPOINT, + SERVERLESS_COMPUTE_RESOURCES_ENDPOINT, + Container, + ContainerDeploymentStatus, + ContainerRegistrySettings, + ContainerRegistryType, + ContainersService, + Deployment, + EnvVar, + EnvVarType, + EntrypointOverridesSettings, + HealthcheckSettings, + RegistryCredential, + Secret, + VolumeMount, + VolumeMountType, + ComputeResource, +) +from datacrunch.exceptions import APIException + +DEPLOYMENT_NAME = "test-deployment" +CONTAINER_NAME = "test-container" +COMPUTE_RESOURCE_NAME = "test-compute" +SECRET_NAME = "test-secret" +SECRET_VALUE = "test-secret-value" +REGISTRY_CREDENTIAL_NAME = "test-credential" +ENV_VAR_NAME = "TEST_VAR" +ENV_VAR_VALUE = "test-value" + +INVALID_REQUEST = "INVALID_REQUEST" +INVALID_REQUEST_MESSAGE = "Invalid request" + +# Sample deployment data for testing +DEPLOYMENT_DATA = { + "name": DEPLOYMENT_NAME, + "container_registry_settings": { + "is_private": False + }, + "containers": [ + { + "name": CONTAINER_NAME, + "image": "nginx:latest", + "exposed_port": 80, + "healthcheck": { + "enabled": True, + "port": 80, + "path": "/health" + }, + "entrypoint_overrides": { + "enabled": False + }, + "env": [ + { + "name": "ENV_VAR1", + "value_or_reference_to_secret": "value1", + "type": "plain" + } + ], + "volume_mounts": [ + { + "type": "scratch", + "mount_path": "/data" + } + ] + } + ], + "compute": { + "name": COMPUTE_RESOURCE_NAME, + "size": 1, + "is_available": True + }, + "is_spot": False, + "endpoint_base_url": "https://test-deployment.datacrunch.io", + "scaling": { + "min_replica_count": 1, + "max_replica_count": 3, + "scale_down_policy": { + "delay_seconds": 300 + }, + "scale_up_policy": { + "delay_seconds": 60 + }, + "queue_message_ttl_seconds": 3600, + "concurrent_requests_per_replica": 10, + "scaling_triggers": { + "queue_load": { + "threshold": 0.75 + }, + "cpu_utilization": { + "enabled": True, + "threshold": 0.8 + }, + "gpu_utilization": { + "enabled": False + } + } + }, + "created_at": "2023-01-01T00:00:00Z" +} + +# Sample compute resources data +COMPUTE_RESOURCES_DATA = [ + { + "name": COMPUTE_RESOURCE_NAME, + "size": 1, + "is_available": True + }, + { + "name": "large-compute", + "size": 4, + "is_available": True + } +] + +# Sample secrets data +SECRETS_DATA = [ + { + "name": SECRET_NAME, + "created_at": "2023-01-01T00:00:00Z" + } +] + +# Sample registry credentials data +REGISTRY_CREDENTIALS_DATA = [ + { + "name": REGISTRY_CREDENTIAL_NAME, + "created_at": "2023-01-01T00:00:00Z" + } +] + +# Sample deployment status data +DEPLOYMENT_STATUS_DATA = { + "status": "healthy" +} + +# Sample replicas data +REPLICAS_DATA = { + "replicas": [ + { + "id": "replica-1", + "status": "running", + "started_at": "2023-01-01T00:00:00Z" + } + ] +} + +# Sample environment variables data +ENV_VARS_DATA = { + "container_name": CONTAINER_NAME, + "env": [ + { + "name": "ENV_VAR1", + "value_or_reference_to_secret": "value1", + "type": "plain" + } + ] +} + + +class TestContainersService: + @pytest.fixture + def containers_service(self, http_client): + return ContainersService(http_client) + + @pytest.fixture + def deployments_endpoint(self, http_client): + return http_client._base_url + CONTAINER_DEPLOYMENTS_ENDPOINT + + @pytest.fixture + def compute_resources_endpoint(self, http_client): + return http_client._base_url + SERVERLESS_COMPUTE_RESOURCES_ENDPOINT + + @pytest.fixture + def secrets_endpoint(self, http_client): + return http_client._base_url + SECRETS_ENDPOINT + + @pytest.fixture + def registry_credentials_endpoint(self, http_client): + return http_client._base_url + CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT + + @responses.activate + def test_get_deployments(self, containers_service, deployments_endpoint): + # arrange - add response mock + responses.add( + responses.GET, + deployments_endpoint, + json=[DEPLOYMENT_DATA], + status=200 + ) + + # act + deployments = containers_service.get() + deployment = deployments[0] + + # assert + assert type(deployments) == list + assert len(deployments) == 1 + assert type(deployment) == Deployment + assert deployment.name == DEPLOYMENT_NAME + assert len(deployment.containers) == 1 + assert type(deployment.containers[0]) == Container + assert type(deployment.compute) == ComputeResource + assert deployment.compute.name == COMPUTE_RESOURCE_NAME + assert responses.assert_call_count(deployments_endpoint, 1) is True + + @responses.activate + def test_get_deployment_by_name(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}" + responses.add( + responses.GET, + url, + json=DEPLOYMENT_DATA, + status=200 + ) + + # act + deployment = containers_service.get_by_name(DEPLOYMENT_NAME) + + # assert + assert type(deployment) == Deployment + assert deployment.name == DEPLOYMENT_NAME + assert len(deployment.containers) == 1 + assert deployment.containers[0].name == CONTAINER_NAME + assert deployment.compute.name == COMPUTE_RESOURCE_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_deployment_by_name_error(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/nonexistent" + responses.add( + responses.GET, + url, + json={"code": INVALID_REQUEST, "message": INVALID_REQUEST_MESSAGE}, + status=400 + ) + + # act + with pytest.raises(APIException) as excinfo: + containers_service.get_by_name("nonexistent") + + # assert + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_create_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + responses.add( + responses.POST, + deployments_endpoint, + json=DEPLOYMENT_DATA, + status=200 + ) + + # create deployment object + container = Container( + name=CONTAINER_NAME, + image="nginx:latest", + exposed_port=80, + healthcheck=HealthcheckSettings( + enabled=True, port=80, path="/health"), + entrypoint_overrides=EntrypointOverridesSettings(enabled=False), + env=[EnvVar( + name="ENV_VAR1", value_or_reference_to_secret="value1", type=EnvVarType.PLAIN)], + volume_mounts=[VolumeMount( + type=VolumeMountType.SCRATCH, mount_path="/data")] + ) + + compute = ComputeResource(name=COMPUTE_RESOURCE_NAME, size=1) + + container_registry_settings = ContainerRegistrySettings( + is_private=False) + + deployment = Deployment( + name=DEPLOYMENT_NAME, + container_registry_settings=container_registry_settings, + containers=[container], + compute=compute, + is_spot=False + ) + + # act + created_deployment = containers_service.create(deployment) + + # assert + assert type(created_deployment) == Deployment + assert created_deployment.name == DEPLOYMENT_NAME + assert len(created_deployment.containers) == 1 + assert created_deployment.containers[0].name == CONTAINER_NAME + assert created_deployment.compute.name == COMPUTE_RESOURCE_NAME + assert responses.assert_call_count(deployments_endpoint, 1) is True + + @responses.activate + def test_update_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}" + responses.add( + responses.PATCH, + url, + json=DEPLOYMENT_DATA, + status=200 + ) + + # create deployment object + container = Container( + name=CONTAINER_NAME, + image="nginx:latest", + exposed_port=80 + ) + + container_registry_settings = ContainerRegistrySettings( + is_private=False) + + compute = ComputeResource(name=COMPUTE_RESOURCE_NAME, size=1) + + deployment = Deployment( + name=DEPLOYMENT_NAME, + container_registry_settings=container_registry_settings, + containers=[container], + compute=compute + ) + + # act + updated_deployment = containers_service.update( + DEPLOYMENT_NAME, deployment) + + # assert + assert type(updated_deployment) == Deployment + assert updated_deployment.name == DEPLOYMENT_NAME + assert len(updated_deployment.containers) == 1 + assert updated_deployment.containers[0].name == CONTAINER_NAME + assert updated_deployment.compute.name == COMPUTE_RESOURCE_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_delete_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}" + responses.add( + responses.DELETE, + url, + status=204 + ) + + # act + containers_service.delete(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_deployment_status(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/status" + responses.add( + responses.GET, + url, + json=DEPLOYMENT_STATUS_DATA, + status=200 + ) + + # act + status = containers_service.get_status(DEPLOYMENT_NAME) + + # assert + assert status == ContainerDeploymentStatus.HEALTHY + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_restart_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/restart" + responses.add( + responses.POST, + url, + status=204 + ) + + # act + containers_service.restart(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_scaling_options(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/scaling" + responses.add( + responses.GET, + url, + json=DEPLOYMENT_DATA["scaling"], + status=200 + ) + + # act + scaling_options = containers_service.get_scaling_options( + DEPLOYMENT_NAME) + + # assert + assert scaling_options["min_replica_count"] == 1 + assert scaling_options["max_replica_count"] == 3 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_update_scaling_options(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/scaling" + responses.add( + responses.PATCH, + url, + json=DEPLOYMENT_DATA["scaling"], + status=200 + ) + + # act + scaling_options = { + "min_replica_count": 1, + "max_replica_count": 5 + } + updated_scaling = containers_service.update_scaling_options( + DEPLOYMENT_NAME, scaling_options) + + # assert + assert updated_scaling["min_replica_count"] == 1 + assert updated_scaling["max_replica_count"] == 3 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_replicas(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/replicas" + responses.add( + responses.GET, + url, + json=REPLICAS_DATA, + status=200 + ) + + # act + replicas = containers_service.get_replicas(DEPLOYMENT_NAME) + + # assert + assert "replicas" in replicas + assert len(replicas["replicas"]) == 1 + assert replicas["replicas"][0]["id"] == "replica-1" + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_purge_queue(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/purge-queue" + responses.add( + responses.POST, + url, + status=204 + ) + + # act + containers_service.purge_queue(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_pause_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/pause" + responses.add( + responses.POST, + url, + status=204 + ) + + # act + containers_service.pause(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_resume_deployment(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/resume" + responses.add( + responses.POST, + url, + status=204 + ) + + # act + containers_service.resume(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_environment_variables(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" + responses.add( + responses.GET, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + env_vars = containers_service.get_environment_variables( + DEPLOYMENT_NAME) + + # assert + assert env_vars["container_name"] == CONTAINER_NAME + assert len(env_vars["env"]) == 1 + assert env_vars["env"][0]["name"] == "ENV_VAR1" + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_add_environment_variables(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" + responses.add( + responses.POST, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + env_vars = [{"name": ENV_VAR_NAME, + "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] + result = containers_service.add_environment_variables( + DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) + + # assert + assert result["container_name"] == CONTAINER_NAME + assert len(result["env"]) == 1 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_update_environment_variables(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" + responses.add( + responses.PATCH, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + env_vars = [{"name": ENV_VAR_NAME, + "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] + result = containers_service.update_environment_variables( + DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) + + # assert + assert result["container_name"] == CONTAINER_NAME + assert len(result["env"]) == 1 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_delete_environment_variables(self, containers_service, deployments_endpoint): + # arrange - add response mock + url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" + responses.add( + responses.DELETE, + url, + json={"container_name": CONTAINER_NAME, "env": []}, + status=200 + ) + + # act + result = containers_service.delete_environment_variables( + DEPLOYMENT_NAME, CONTAINER_NAME, [ENV_VAR_NAME]) + + # assert + assert result["container_name"] == CONTAINER_NAME + assert len(result["env"]) == 0 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_compute_resources(self, containers_service, compute_resources_endpoint): + # arrange - add response mock + responses.add( + responses.GET, + compute_resources_endpoint, + json=COMPUTE_RESOURCES_DATA, + status=200 + ) + + # act + resources = containers_service.get_compute_resources() + + # assert + assert type(resources) == list + assert len(resources) == 2 + assert type(resources[0]) == ComputeResource + assert resources[0].name == COMPUTE_RESOURCE_NAME + assert resources[0].size == 1 + assert resources[0].is_available == True + assert responses.assert_call_count( + compute_resources_endpoint, 1) is True + + @responses.activate + def test_get_secrets(self, containers_service, secrets_endpoint): + # arrange - add response mock + responses.add( + responses.GET, + secrets_endpoint, + json=SECRETS_DATA, + status=200 + ) + + # act + secrets = containers_service.get_secrets() + + # assert + assert type(secrets) == list + assert len(secrets) == 1 + assert type(secrets[0]) == Secret + assert secrets[0].name == SECRET_NAME + assert responses.assert_call_count(secrets_endpoint, 1) is True + + @responses.activate + def test_create_secret(self, containers_service, secrets_endpoint): + # arrange - add response mock + responses.add( + responses.POST, + secrets_endpoint, + json=SECRETS_DATA[0], + status=200 + ) + + # act + secret = containers_service.create_secret(SECRET_NAME, SECRET_VALUE) + + # assert + assert type(secret) == Secret + assert secret.name == SECRET_NAME + assert responses.assert_call_count(secrets_endpoint, 1) is True + + @responses.activate + def test_delete_secret(self, containers_service, secrets_endpoint): + # arrange - add response mock + url = f"{secrets_endpoint}/{SECRET_NAME}" + responses.add( + responses.DELETE, + url, + status=200 + ) + + # act + containers_service.delete_secret(SECRET_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_registry_credentials(self, containers_service, registry_credentials_endpoint): + # arrange - add response mock + responses.add( + responses.GET, + registry_credentials_endpoint, + json=REGISTRY_CREDENTIALS_DATA, + status=200 + ) + + # act + credentials = containers_service.get_registry_credentials() + + # assert + assert type(credentials) == list + assert len(credentials) == 1 + assert type(credentials[0]) == RegistryCredential + assert credentials[0].name == REGISTRY_CREDENTIAL_NAME + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + + @responses.activate + def test_add_registry_credentials(self, containers_service, registry_credentials_endpoint): + # arrange - add response mock + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + containers_service.add_registry_credentials( + REGISTRY_CREDENTIAL_NAME, + ContainerRegistryType.DOCKERHUB, + "username", + "token" + ) + + # assert + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + + @responses.activate + def test_add_registry_credentials_validation_error(self, containers_service): + # act & assert + with pytest.raises(ValueError) as excinfo: + containers_service.add_registry_credentials( + REGISTRY_CREDENTIAL_NAME, + ContainerRegistryType.DOCKERHUB, + # Missing username and token + ) + assert "Username and access_token are required" in str(excinfo.value) + + @responses.activate + def test_add_registry_credentials_gcr(self, containers_service, registry_credentials_endpoint): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + service_account_key = '{"key": "value"}' + containers_service.add_registry_credentials( + REGISTRY_CREDENTIAL_NAME, + ContainerRegistryType.GCR, + service_account_key=service_account_key + ) + + # assert + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + + @responses.activate + def test_add_registry_credentials_aws_ecr(self, containers_service, registry_credentials_endpoint): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + containers_service.add_registry_credentials( + REGISTRY_CREDENTIAL_NAME, + ContainerRegistryType.AWS_ECR, + access_key_id="test-key", + secret_access_key="test-secret", + region="us-west-2", + ecr_repo="test.ecr.aws.com" + ) + + # assert + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + + @responses.activate + def test_add_registry_credentials_custom(self, containers_service, registry_credentials_endpoint): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + docker_config = '{"auths": {"registry.example.com": {"auth": "base64-encoded"}}}' + containers_service.add_registry_credentials( + REGISTRY_CREDENTIAL_NAME, + ContainerRegistryType.CUSTOM, + docker_config_json=docker_config + ) + + # assert + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + + @responses.activate + def test_delete_secret_with_force(self, containers_service, secrets_endpoint): + # arrange + url = f"{secrets_endpoint}/{SECRET_NAME}" + responses.add( + responses.DELETE, + url, + status=200 + ) + + # act + containers_service.delete_secret(SECRET_NAME, force=True) + + # assert + assert responses.assert_call_count(url, 1) is True + # Verify the request included force=True parameter + request = responses.calls[0].request + assert "force=True" in request.url + + @responses.activate + def test_delete_registry_credentials(self, containers_service, registry_credentials_endpoint): + # arrange - add response mock + url = f"{registry_credentials_endpoint}/{REGISTRY_CREDENTIAL_NAME}" + responses.add( + responses.DELETE, + url, + status=200 + ) + + # act + containers_service.delete_registry_credentials( + REGISTRY_CREDENTIAL_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True From 9ed11319a8623b90024dae272c0fe78bc0246715 Mon Sep 17 00:00:00 2001 From: Tamir Date: Tue, 18 Mar 2025 15:52:36 +0200 Subject: [PATCH 08/24] fixes --- .../unit_tests/containers/test_containers.py | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 55b28de..11abf5a 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -21,6 +21,11 @@ VolumeMount, VolumeMountType, ComputeResource, + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, ) from datacrunch.exceptions import APIException @@ -407,8 +412,9 @@ def test_get_scaling_options(self, containers_service, deployments_endpoint): DEPLOYMENT_NAME) # assert - assert scaling_options["min_replica_count"] == 1 - assert scaling_options["max_replica_count"] == 3 + assert isinstance(scaling_options, ScalingOptions) + assert scaling_options.min_replica_count == 1 + assert scaling_options.max_replica_count == 3 assert responses.assert_call_count(url, 1) is True @responses.activate @@ -422,17 +428,30 @@ def test_update_scaling_options(self, containers_service, deployments_endpoint): status=200 ) + # create scaling options object + scaling_options = ScalingOptions( + min_replica_count=1, + max_replica_count=5, + scale_down_policy=ScalingPolicy(delay_seconds=300), + scale_up_policy=ScalingPolicy(delay_seconds=60), + queue_message_ttl_seconds=3600, + concurrent_requests_per_replica=10, + scaling_triggers=ScalingTriggers( + queue_load=QueueLoadScalingTrigger(threshold=0.75), + cpu_utilization=UtilizationScalingTrigger( + enabled=True, threshold=0.8), + gpu_utilization=UtilizationScalingTrigger(enabled=False) + ) + ) + # act - scaling_options = { - "min_replica_count": 1, - "max_replica_count": 5 - } updated_scaling = containers_service.update_scaling_options( DEPLOYMENT_NAME, scaling_options) # assert - assert updated_scaling["min_replica_count"] == 1 - assert updated_scaling["max_replica_count"] == 3 + assert isinstance(updated_scaling, ScalingOptions) + assert updated_scaling.min_replica_count == 1 + assert updated_scaling.max_replica_count == 3 assert responses.assert_call_count(url, 1) is True @responses.activate @@ -664,6 +683,26 @@ def test_delete_secret(self, containers_service, secrets_endpoint): # assert assert responses.assert_call_count(url, 1) is True + request = responses.calls[0].request + assert "force=False" in request.url + + @responses.activate + def test_delete_secret_with_force(self, containers_service, secrets_endpoint): + # arrange + url = f"{secrets_endpoint}/{SECRET_NAME}" + responses.add( + responses.DELETE, + url, + status=200 + ) + + # act + containers_service.delete_secret(SECRET_NAME, force=True) + + # assert + assert responses.assert_call_count(url, 1) is True + request = responses.calls[0].request + assert "force=True" in request.url @responses.activate def test_get_registry_credentials(self, containers_service, registry_credentials_endpoint): @@ -783,25 +822,6 @@ def test_add_registry_credentials_custom(self, containers_service, registry_cred assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - @responses.activate - def test_delete_secret_with_force(self, containers_service, secrets_endpoint): - # arrange - url = f"{secrets_endpoint}/{SECRET_NAME}" - responses.add( - responses.DELETE, - url, - status=200 - ) - - # act - containers_service.delete_secret(SECRET_NAME, force=True) - - # assert - assert responses.assert_call_count(url, 1) is True - # Verify the request included force=True parameter - request = responses.calls[0].request - assert "force=True" in request.url - @responses.activate def test_delete_registry_credentials(self, containers_service, registry_credentials_endpoint): # arrange - add response mock From a92add0c0b02187fa4c5945f67c56063e9eb4e36 Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 07:39:17 +0200 Subject: [PATCH 09/24] fix url --- tests/unit_tests/containers/test_containers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 11abf5a..d6568b1 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -671,7 +671,7 @@ def test_create_secret(self, containers_service, secrets_endpoint): @responses.activate def test_delete_secret(self, containers_service, secrets_endpoint): # arrange - add response mock - url = f"{secrets_endpoint}/{SECRET_NAME}" + url = f"{secrets_endpoint}/{SECRET_NAME}/?force=false" responses.add( responses.DELETE, url, @@ -689,7 +689,7 @@ def test_delete_secret(self, containers_service, secrets_endpoint): @responses.activate def test_delete_secret_with_force(self, containers_service, secrets_endpoint): # arrange - url = f"{secrets_endpoint}/{SECRET_NAME}" + url = f"{secrets_endpoint}/{SECRET_NAME}/?force=true" responses.add( responses.DELETE, url, From 569eed7fafa6e72409b79cada36eb8e2b5576d9e Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 07:46:25 +0200 Subject: [PATCH 10/24] small refactor, fix test --- datacrunch/containers/containers.py | 4 +-- examples/containers/container_deployments.py | 29 ++++++++++--------- .../registry_credentials_example.py | 22 +++++++++----- examples/containers/sglang_deployment.py | 22 +++++++------- .../containers/update_deployment_scaling.py | 8 ++--- .../unit_tests/containers/test_containers.py | 4 +-- 6 files changed, 49 insertions(+), 40 deletions(-) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index a71c449..61e0497 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -9,8 +9,8 @@ # API endpoints CONTAINER_DEPLOYMENTS_ENDPOINT = '/container-deployments' SERVERLESS_COMPUTE_RESOURCES_ENDPOINT = '/serverless-compute-resources' -SECRETS_ENDPOINT = '/secrets' CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT = '/container-registry-credentials' +SECRETS_ENDPOINT = '/secrets' class EnvVarType(str, Enum): @@ -478,7 +478,7 @@ def delete_secret(self, secret_name: str, force: bool = False) -> None: :type force: bool """ self.client.delete( - f"{SECRETS_ENDPOINT}/{secret_name}", params={"force": force}) + f"{SECRETS_ENDPOINT}/{secret_name}", params={"force": str(force).lower()}) def get_registry_credentials(self) -> List[RegistryCredential]: """Get all registry credentials diff --git a/examples/containers/container_deployments.py b/examples/containers/container_deployments.py index 994cf7b..6cf2a9e 100644 --- a/examples/containers/container_deployments.py +++ b/examples/containers/container_deployments.py @@ -35,7 +35,7 @@ DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') # DataCrunch client instance -datacrunch = None +datacrunch_client = None def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 10, delay: int = 30) -> bool: @@ -87,8 +87,8 @@ def main() -> None: return # Initialize client - global datacrunch - datacrunch = DataCrunchClient( + global datacrunch_client + datacrunch_client = DataCrunchClient( DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) # Create container configuration @@ -145,18 +145,19 @@ def main() -> None: ) # Create the deployment - created_deployment = datacrunch.containers.create(deployment) + created_deployment = datacrunch_client.containers.create(deployment) print(f"Created deployment: {created_deployment.name}") # Wait for deployment to be healthy - if not wait_for_deployment_health(datacrunch, DEPLOYMENT_NAME): + if not wait_for_deployment_health(datacrunch_client, DEPLOYMENT_NAME): print("Deployment health check failed") - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) return # Update scaling configuration try: - deployment = datacrunch.containers.get_by_name(DEPLOYMENT_NAME) + deployment = datacrunch_client.containers.get_by_name( + DEPLOYMENT_NAME) # Create new scaling options with increased replica counts deployment.scaling = ScalingOptions( min_replica_count=2, @@ -177,7 +178,7 @@ def main() -> None: ) ) ) - updated_deployment = datacrunch.containers.update( + updated_deployment = datacrunch_client.containers.update( DEPLOYMENT_NAME, deployment) print(f"Updated deployment scaling: {updated_deployment.name}") except APIException as e: @@ -186,32 +187,32 @@ def main() -> None: # Demonstrate deployment operations try: # Pause deployment - datacrunch.containers.pause(DEPLOYMENT_NAME) + datacrunch_client.containers.pause(DEPLOYMENT_NAME) print("Deployment paused") time.sleep(60) # Resume deployment - datacrunch.containers.resume(DEPLOYMENT_NAME) + datacrunch_client.containers.resume(DEPLOYMENT_NAME) print("Deployment resumed") # Restart deployment - datacrunch.containers.restart(DEPLOYMENT_NAME) + datacrunch_client.containers.restart(DEPLOYMENT_NAME) print("Deployment restarted") # Purge queue - datacrunch.containers.purge_queue(DEPLOYMENT_NAME) + datacrunch_client.containers.purge_queue(DEPLOYMENT_NAME) print("Queue purged") except APIException as e: print(f"Error in deployment operations: {e}") # Clean up - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) except Exception as e: print(f"Unexpected error: {e}") # Attempt cleanup even if there was an error try: - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) except Exception as cleanup_error: print(f"Error during cleanup after failure: {cleanup_error}") diff --git a/examples/containers/registry_credentials_example.py b/examples/containers/registry_credentials_example.py index df10446..aa0775c 100644 --- a/examples/containers/registry_credentials_example.py +++ b/examples/containers/registry_credentials_example.py @@ -7,11 +7,11 @@ DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') # Initialize DataCrunch client -client = DataCrunchClient(client_id=DATACRUNCH_CLIENT_ID, - client_secret=DATACRUNCH_CLIENT_SECRET) +datacrunch_client = DataCrunchClient(client_id=DATACRUNCH_CLIENT_ID, + client_secret=DATACRUNCH_CLIENT_SECRET) # Example 1: DockerHub Credentials -client.containers.add_registry_credentials( +datacrunch_client.containers.add_registry_credentials( name="my-dockerhub-creds", registry_type=ContainerRegistryType.DOCKERHUB, username="your-dockerhub-username", @@ -20,7 +20,7 @@ print("Created DockerHub credentials") # Example 2: GitHub Container Registry Credentials -client.containers.add_registry_credentials( +datacrunch_client.containers.add_registry_credentials( name="my-github-creds", registry_type=ContainerRegistryType.GITHUB, username="your-github-username", @@ -43,7 +43,7 @@ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project.iam.gserviceaccount.com" }""" -client.containers.add_registry_credentials( +datacrunch_client.containers.add_registry_credentials( name="my-gcr-creds", registry_type=ContainerRegistryType.GCR, service_account_key=gcr_service_account_key @@ -51,7 +51,7 @@ print("Created GCR credentials") # Example 4: AWS ECR Credentials -client.containers.add_registry_credentials( +datacrunch_client.containers.add_registry_credentials( name="my-aws-ecr-creds", registry_type=ContainerRegistryType.AWS_ECR, access_key_id="your-aws-access-key-id", @@ -70,9 +70,17 @@ } }""" -client.containers.add_registry_credentials( +datacrunch_client.containers.add_registry_credentials( name="my-custom-registry-creds", registry_type=ContainerRegistryType.CUSTOM, docker_config_json=custom_docker_config ) print("Created Custom registry credentials") + +# Delete all registry credentials +datacrunch_client.containers.delete_registry_credentials('my-dockerhub-creds') +datacrunch_client.containers.delete_registry_credentials('my-github-creds') +datacrunch_client.containers.delete_registry_credentials('my-gcr-creds') +datacrunch_client.containers.delete_registry_credentials('my-aws-ecr-creds') +datacrunch_client.containers.delete_registry_credentials( + 'my-custom-registry-creds') diff --git a/examples/containers/sglang_deployment.py b/examples/containers/sglang_deployment.py index 92fc8f1..2c83565 100644 --- a/examples/containers/sglang_deployment.py +++ b/examples/containers/sglang_deployment.py @@ -44,7 +44,7 @@ CONTAINERS_API_URL = f'https://containers.datacrunch.io/{DEPLOYMENT_NAME}' # DataCrunch client instance (global for graceful shutdown) -datacrunch = None +datacrunch_client = None def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 20, delay: int = 30) -> bool: @@ -92,7 +92,7 @@ def graceful_shutdown(signum, frame) -> None: """Handle graceful shutdown on signals.""" print(f"\nSignal {signum} received, cleaning up resources...") try: - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) except Exception as e: print(f"Error during cleanup: {e}") sys.exit(0) @@ -176,8 +176,8 @@ def main() -> None: return # Initialize client - global datacrunch - datacrunch = DataCrunchClient( + global datacrunch_client + datacrunch_client = DataCrunchClient( DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) # Register signal handlers for cleanup @@ -188,12 +188,12 @@ def main() -> None: print(f"Creating secret for Hugging Face token: {HF_SECRET_NAME}") try: # Check if secret already exists - existing_secrets = datacrunch.containers.get_secrets() + existing_secrets = datacrunch_client.containers.get_secrets() secret_exists = any( secret.name == HF_SECRET_NAME for secret in existing_secrets) if not secret_exists: - datacrunch.containers.create_secret( + datacrunch_client.containers.create_secret( HF_SECRET_NAME, HF_TOKEN) print(f"Secret '{HF_SECRET_NAME}' created successfully") else: @@ -264,14 +264,14 @@ def main() -> None: ) # Create the deployment - created_deployment = datacrunch.containers.create(deployment) + created_deployment = datacrunch_client.containers.create(deployment) print(f"Created deployment: {created_deployment.name}") print("This will take several minutes while the model is downloaded and the server starts...") # Wait for deployment to be healthy - if not wait_for_deployment_health(datacrunch, DEPLOYMENT_NAME): + if not wait_for_deployment_health(datacrunch_client, DEPLOYMENT_NAME): print("Deployment health check failed") - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) return # Get the deployment endpoint URL and inference API key @@ -301,7 +301,7 @@ def main() -> None: keep_running = input( "\nDo you want to keep the deployment running? (y/n): ") if keep_running.lower() != 'y': - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) else: print( f"Deployment {DEPLOYMENT_NAME} is running. Don't forget to delete it when finished.") @@ -312,7 +312,7 @@ def main() -> None: print(f"Unexpected error: {e}") # Attempt cleanup even if there was an error try: - cleanup_resources(datacrunch) + cleanup_resources(datacrunch_client) except Exception as cleanup_error: print(f"Error during cleanup after failure: {cleanup_error}") diff --git a/examples/containers/update_deployment_scaling.py b/examples/containers/update_deployment_scaling.py index ac8f0e1..60ae40e 100644 --- a/examples/containers/update_deployment_scaling.py +++ b/examples/containers/update_deployment_scaling.py @@ -94,19 +94,19 @@ def main() -> None: return # Initialize client - client = DataCrunchClient( + datacrunch_client = DataCrunchClient( DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) # Verify deployment exists - if not check_deployment_exists(client, DEPLOYMENT_NAME): + if not check_deployment_exists(datacrunch_client, DEPLOYMENT_NAME): print(f"Deployment {DEPLOYMENT_NAME} does not exist.") return # Update scaling options using the API - update_deployment_scaling(client, DEPLOYMENT_NAME) + update_deployment_scaling(datacrunch_client, DEPLOYMENT_NAME) # Get current scaling options - scaling_options = client.containers.get_scaling_options( + scaling_options = datacrunch_client.containers.get_scaling_options( DEPLOYMENT_NAME) print(f"\nCurrent scaling configuration:") print(f"Min replicas: {scaling_options.min_replica_count}") diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index d6568b1..2e78ec5 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -684,7 +684,7 @@ def test_delete_secret(self, containers_service, secrets_endpoint): # assert assert responses.assert_call_count(url, 1) is True request = responses.calls[0].request - assert "force=False" in request.url + assert "force=false" in request.url @responses.activate def test_delete_secret_with_force(self, containers_service, secrets_endpoint): @@ -702,7 +702,7 @@ def test_delete_secret_with_force(self, containers_service, secrets_endpoint): # assert assert responses.assert_call_count(url, 1) is True request = responses.calls[0].request - assert "force=True" in request.url + assert "force=true" in request.url @responses.activate def test_get_registry_credentials(self, containers_service, registry_credentials_endpoint): From f7adefd6f4977384ecae071b53ba396720c5a6fd Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 07:49:56 +0200 Subject: [PATCH 11/24] another url fix --- tests/unit_tests/containers/test_containers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 2e78ec5..d83eea6 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -671,7 +671,7 @@ def test_create_secret(self, containers_service, secrets_endpoint): @responses.activate def test_delete_secret(self, containers_service, secrets_endpoint): # arrange - add response mock - url = f"{secrets_endpoint}/{SECRET_NAME}/?force=false" + url = f"{secrets_endpoint}/{SECRET_NAME}?force=false" responses.add( responses.DELETE, url, @@ -689,7 +689,7 @@ def test_delete_secret(self, containers_service, secrets_endpoint): @responses.activate def test_delete_secret_with_force(self, containers_service, secrets_endpoint): # arrange - url = f"{secrets_endpoint}/{SECRET_NAME}/?force=true" + url = f"{secrets_endpoint}/{SECRET_NAME}?force=true" responses.add( responses.DELETE, url, From b3aebd8dfa756bea00c4ac0e4e977164ab8d60cc Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 07:57:10 +0200 Subject: [PATCH 12/24] update datetime strings to work with lower versions of python --- tests/unit_tests/containers/test_containers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index d83eea6..1b880f3 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -106,7 +106,7 @@ } } }, - "created_at": "2023-01-01T00:00:00Z" + "created_at": "2023-01-01T00:00:00+00:00" } # Sample compute resources data @@ -127,7 +127,7 @@ SECRETS_DATA = [ { "name": SECRET_NAME, - "created_at": "2023-01-01T00:00:00Z" + "created_at": "2023-01-01T00:00:00+00:00" } ] @@ -135,7 +135,7 @@ REGISTRY_CREDENTIALS_DATA = [ { "name": REGISTRY_CREDENTIAL_NAME, - "created_at": "2023-01-01T00:00:00Z" + "created_at": "2023-01-01T00:00:00+00:00" } ] @@ -150,7 +150,7 @@ { "id": "replica-1", "status": "running", - "started_at": "2023-01-01T00:00:00Z" + "started_at": "2023-01-01T00:00:00+00:00" } ] } From 546c9b3cfabe01928745f0bac6e969bd80faed5d Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 08:31:26 +0200 Subject: [PATCH 13/24] refactor: more verbose method names --- datacrunch/containers/containers.py | 38 +++++++------- examples/containers/container_deployments.py | 20 +++---- .../registry_credentials_example.py | 8 +-- examples/containers/sglang_deployment.py | 9 ++-- .../containers/update_deployment_scaling.py | 6 +-- .../unit_tests/containers/test_containers.py | 52 +++++++++---------- 6 files changed, 68 insertions(+), 65 deletions(-) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 61e0497..706986d 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -63,13 +63,13 @@ class EntrypointOverridesSettings: class EnvVar: name: str value_or_reference_to_secret: str - type: EnvVarType # "plain" or "secret" + type: EnvVarType @dataclass_json @dataclass class VolumeMount: - type: VolumeMountType # "scratch" or "secret" + type: VolumeMountType mount_path: str @@ -220,7 +220,7 @@ def __init__(self, http_client) -> None: """ self.client = http_client - def get(self) -> List[Deployment]: + def get_deployments(self) -> List[Deployment]: """Get all deployments :return: list of deployments @@ -229,7 +229,7 @@ def get(self) -> List[Deployment]: response = self.client.get(CONTAINER_DEPLOYMENTS_ENDPOINT) return [Deployment.from_dict(deployment, infer_missing=True) for deployment in response.json()] - def get_by_name(self, deployment_name: str) -> Deployment: + def get_deployment_by_name(self, deployment_name: str) -> Deployment: """Get a deployment by name :param deployment_name: name of the deployment @@ -241,7 +241,7 @@ def get_by_name(self, deployment_name: str) -> Deployment: f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}") return Deployment.from_dict(response.json(), infer_missing=True) - def create( + def create_deployment( self, deployment: Deployment ) -> Deployment: @@ -258,7 +258,7 @@ def create( ) return Deployment.from_dict(response.json(), infer_missing=True) - def update(self, deployment_name: str, deployment: Deployment) -> Deployment: + def update_deployment(self, deployment_name: str, deployment: Deployment) -> Deployment: """Update an existing deployment :param deployment_name: name of the deployment to update @@ -274,7 +274,7 @@ def update(self, deployment_name: str, deployment: Deployment) -> Deployment: ) return Deployment.from_dict(response.json(), infer_missing=True) - def delete(self, deployment_name: str) -> None: + def delete_deployment(self, deployment_name: str) -> None: """Delete a deployment :param deployment_name: name of the deployment to delete @@ -283,7 +283,7 @@ def delete(self, deployment_name: str) -> None: self.client.delete( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}") - def get_status(self, deployment_name: str) -> ContainerDeploymentStatus: + def get_deployment_status(self, deployment_name: str) -> ContainerDeploymentStatus: """Get deployment status :param deployment_name: name of the deployment @@ -295,7 +295,7 @@ def get_status(self, deployment_name: str) -> ContainerDeploymentStatus: f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/status") return ContainerDeploymentStatus(response.json()["status"]) - def restart(self, deployment_name: str) -> None: + def restart_deployment(self, deployment_name: str) -> None: """Restart a deployment :param deployment_name: name of the deployment to restart @@ -304,7 +304,7 @@ def restart(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/restart") - def get_scaling_options(self, deployment_name: str) -> ScalingOptions: + def get_deployment_scaling_options(self, deployment_name: str) -> ScalingOptions: """Get deployment scaling options :param deployment_name: name of the deployment @@ -316,7 +316,7 @@ def get_scaling_options(self, deployment_name: str) -> ScalingOptions: f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling") return ScalingOptions.from_dict(response.json()) - def update_scaling_options(self, deployment_name: str, scaling_options: ScalingOptions) -> ScalingOptions: + def update_deployment_scaling_options(self, deployment_name: str, scaling_options: ScalingOptions) -> ScalingOptions: """Update deployment scaling options :param deployment_name: name of the deployment @@ -332,7 +332,7 @@ def update_scaling_options(self, deployment_name: str, scaling_options: ScalingO ) return ScalingOptions.from_dict(response.json()) - def get_replicas(self, deployment_name: str) -> Dict: + def get_deployment_replicas(self, deployment_name: str) -> Dict: """Get deployment replicas :param deployment_name: name of the deployment @@ -344,7 +344,7 @@ def get_replicas(self, deployment_name: str) -> Dict: f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/replicas") return response.json() - def purge_queue(self, deployment_name: str) -> None: + def purge_deployment_queue(self, deployment_name: str) -> None: """Purge deployment queue :param deployment_name: name of the deployment @@ -353,7 +353,7 @@ def purge_queue(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/purge-queue") - def pause(self, deployment_name: str) -> None: + def pause_deployment(self, deployment_name: str) -> None: """Pause a deployment :param deployment_name: name of the deployment to pause @@ -362,7 +362,7 @@ def pause(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/pause") - def resume(self, deployment_name: str) -> None: + def resume_deployment(self, deployment_name: str) -> None: """Resume a deployment :param deployment_name: name of the deployment to resume @@ -371,7 +371,7 @@ def resume(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/resume") - def get_environment_variables(self, deployment_name: str) -> Dict: + def get_deployment_environment_variables(self, deployment_name: str) -> Dict: """Get deployment environment variables :param deployment_name: name of the deployment @@ -383,7 +383,7 @@ def get_environment_variables(self, deployment_name: str) -> Dict: f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables") return response.json() - def add_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + def add_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: """Add environment variables to a container :param deployment_name: name of the deployment @@ -401,7 +401,7 @@ def add_environment_variables(self, deployment_name: str, container_name: str, e ) return response.json() - def update_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + def update_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: """Update environment variables of a container :param deployment_name: name of the deployment @@ -419,7 +419,7 @@ def update_environment_variables(self, deployment_name: str, container_name: str ) return response.json() - def delete_environment_variables(self, deployment_name: str, container_name: str, env_var_names: List[str]) -> Dict: + def delete_deployment_environment_variables(self, deployment_name: str, container_name: str, env_var_names: List[str]) -> Dict: """Delete environment variables from a container :param deployment_name: name of the deployment diff --git a/examples/containers/container_deployments.py b/examples/containers/container_deployments.py index 6cf2a9e..f0cd992 100644 --- a/examples/containers/container_deployments.py +++ b/examples/containers/container_deployments.py @@ -52,7 +52,7 @@ def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, m """ for attempt in range(max_attempts): try: - status = client.containers.get_status(deployment_name) + status = client.containers.get_deployment_status(deployment_name) print(f"Deployment status: {status}") if status == ContainerDeploymentStatus.HEALTHY: return True @@ -71,7 +71,7 @@ def cleanup_resources(client: DataCrunchClient) -> None: """ try: # Delete deployment - client.containers.delete(DEPLOYMENT_NAME) + client.containers.delete_deployment(DEPLOYMENT_NAME) print("Deployment deleted") except APIException as e: print(f"Error during cleanup: {e}") @@ -145,7 +145,8 @@ def main() -> None: ) # Create the deployment - created_deployment = datacrunch_client.containers.create(deployment) + created_deployment = datacrunch_client.containers.create_deployment( + deployment) print(f"Created deployment: {created_deployment.name}") # Wait for deployment to be healthy @@ -156,7 +157,7 @@ def main() -> None: # Update scaling configuration try: - deployment = datacrunch_client.containers.get_by_name( + deployment = datacrunch_client.containers.get_deployment_by_name( DEPLOYMENT_NAME) # Create new scaling options with increased replica counts deployment.scaling = ScalingOptions( @@ -178,7 +179,7 @@ def main() -> None: ) ) ) - updated_deployment = datacrunch_client.containers.update( + updated_deployment = datacrunch_client.containers.update_deployment( DEPLOYMENT_NAME, deployment) print(f"Updated deployment scaling: {updated_deployment.name}") except APIException as e: @@ -187,20 +188,21 @@ def main() -> None: # Demonstrate deployment operations try: # Pause deployment - datacrunch_client.containers.pause(DEPLOYMENT_NAME) + datacrunch_client.containers.pause_deployment(DEPLOYMENT_NAME) print("Deployment paused") time.sleep(60) # Resume deployment - datacrunch_client.containers.resume(DEPLOYMENT_NAME) + datacrunch_client.containers.resume_deployment(DEPLOYMENT_NAME) print("Deployment resumed") # Restart deployment - datacrunch_client.containers.restart(DEPLOYMENT_NAME) + datacrunch_client.containers.restart_deployment(DEPLOYMENT_NAME) print("Deployment restarted") # Purge queue - datacrunch_client.containers.purge_queue(DEPLOYMENT_NAME) + datacrunch_client.containers.purge_deployment_queue( + DEPLOYMENT_NAME) print("Queue purged") except APIException as e: print(f"Error in deployment operations: {e}") diff --git a/examples/containers/registry_credentials_example.py b/examples/containers/registry_credentials_example.py index aa0775c..9820313 100644 --- a/examples/containers/registry_credentials_example.py +++ b/examples/containers/registry_credentials_example.py @@ -54,10 +54,10 @@ datacrunch_client.containers.add_registry_credentials( name="my-aws-ecr-creds", registry_type=ContainerRegistryType.AWS_ECR, - access_key_id="your-aws-access-key-id", - secret_access_key="your-aws-secret-access-key", - region="us-west-2", - ecr_repo="123456789012.dkr.ecr.us-west-2.amazonaws.com" + access_key_id="AKIAEXAMPLE123456", + secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + region="eu-north-1", + ecr_repo="887841266746.dkr.ecr.eu-north-1.amazonaws.com" ) print("Created AWS ECR credentials") diff --git a/examples/containers/sglang_deployment.py b/examples/containers/sglang_deployment.py index 2c83565..43e7195 100644 --- a/examples/containers/sglang_deployment.py +++ b/examples/containers/sglang_deployment.py @@ -47,7 +47,7 @@ datacrunch_client = None -def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, max_attempts: int = 20, delay: int = 30) -> bool: +def wait_for_deployment_health(datacrunch_client: DataCrunchClient, deployment_name: str, max_attempts: int = 20, delay: int = 30) -> bool: """Wait for deployment to reach healthy status. Args: @@ -62,7 +62,8 @@ def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, m print(f"Waiting for deployment to be healthy (may take several minutes to download model)...") for attempt in range(max_attempts): try: - status = client.containers.get_status(deployment_name) + status = datacrunch_client.containers.get_deployment_status( + deployment_name) print( f"Attempt {attempt+1}/{max_attempts} - Deployment status: {status}") if status == ContainerDeploymentStatus.HEALTHY: @@ -74,7 +75,7 @@ def wait_for_deployment_health(client: DataCrunchClient, deployment_name: str, m return False -def cleanup_resources(client: DataCrunchClient) -> None: +def cleanup_resources(datacrunch_client: DataCrunchClient) -> None: """Clean up all created resources. Args: @@ -82,7 +83,7 @@ def cleanup_resources(client: DataCrunchClient) -> None: """ try: # Delete deployment - client.containers.delete(DEPLOYMENT_NAME) + datacrunch_client.containers.delete_deployment(DEPLOYMENT_NAME) print("Deployment deleted") except APIException as e: print(f"Error during cleanup: {e}") diff --git a/examples/containers/update_deployment_scaling.py b/examples/containers/update_deployment_scaling.py index 60ae40e..e698b40 100644 --- a/examples/containers/update_deployment_scaling.py +++ b/examples/containers/update_deployment_scaling.py @@ -34,7 +34,7 @@ def check_deployment_exists(client: DataCrunchClient, deployment_name: str) -> b bool: True if deployment exists, False otherwise """ try: - client.containers.get_by_name(deployment_name) + client.containers.get_deployment_by_name(deployment_name) return True except APIException as e: print(f"Error: {e}") @@ -71,7 +71,7 @@ def update_deployment_scaling(client: DataCrunchClient, deployment_name: str) -> ) # Update scaling options - updated_options = client.containers.update_scaling_options( + updated_options = client.containers.update_deployment_scaling_options( deployment_name, scaling_options) print(f"Updated deployment scaling options") print(f"New min replicas: {updated_options.min_replica_count}") @@ -106,7 +106,7 @@ def main() -> None: update_deployment_scaling(datacrunch_client, DEPLOYMENT_NAME) # Get current scaling options - scaling_options = datacrunch_client.containers.get_scaling_options( + scaling_options = datacrunch_client.containers.get_deployment_scaling_options( DEPLOYMENT_NAME) print(f"\nCurrent scaling configuration:") print(f"Min replicas: {scaling_options.min_replica_count}") diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 1b880f3..22021ca 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -200,7 +200,7 @@ def test_get_deployments(self, containers_service, deployments_endpoint): ) # act - deployments = containers_service.get() + deployments = containers_service.get_deployments() deployment = deployments[0] # assert @@ -226,7 +226,7 @@ def test_get_deployment_by_name(self, containers_service, deployments_endpoint): ) # act - deployment = containers_service.get_by_name(DEPLOYMENT_NAME) + deployment = containers_service.get_deployment_by_name(DEPLOYMENT_NAME) # assert assert type(deployment) == Deployment @@ -249,7 +249,7 @@ def test_get_deployment_by_name_error(self, containers_service, deployments_endp # act with pytest.raises(APIException) as excinfo: - containers_service.get_by_name("nonexistent") + containers_service.get_deployment_by_name("nonexistent") # assert assert excinfo.value.code == INVALID_REQUEST @@ -294,7 +294,7 @@ def test_create_deployment(self, containers_service, deployments_endpoint): ) # act - created_deployment = containers_service.create(deployment) + created_deployment = containers_service.create_deployment(deployment) # assert assert type(created_deployment) == Deployment @@ -335,7 +335,7 @@ def test_update_deployment(self, containers_service, deployments_endpoint): ) # act - updated_deployment = containers_service.update( + updated_deployment = containers_service.update_deployment( DEPLOYMENT_NAME, deployment) # assert @@ -357,7 +357,7 @@ def test_delete_deployment(self, containers_service, deployments_endpoint): ) # act - containers_service.delete(DEPLOYMENT_NAME) + containers_service.delete_deployment(DEPLOYMENT_NAME) # assert assert responses.assert_call_count(url, 1) is True @@ -374,7 +374,7 @@ def test_get_deployment_status(self, containers_service, deployments_endpoint): ) # act - status = containers_service.get_status(DEPLOYMENT_NAME) + status = containers_service.get_deployment_status(DEPLOYMENT_NAME) # assert assert status == ContainerDeploymentStatus.HEALTHY @@ -391,13 +391,13 @@ def test_restart_deployment(self, containers_service, deployments_endpoint): ) # act - containers_service.restart(DEPLOYMENT_NAME) + containers_service.restart_deployment(DEPLOYMENT_NAME) # assert assert responses.assert_call_count(url, 1) is True @responses.activate - def test_get_scaling_options(self, containers_service, deployments_endpoint): + def test_get_deployment_scaling_options(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/scaling" responses.add( @@ -408,7 +408,7 @@ def test_get_scaling_options(self, containers_service, deployments_endpoint): ) # act - scaling_options = containers_service.get_scaling_options( + scaling_options = containers_service.get_deployment_scaling_options( DEPLOYMENT_NAME) # assert @@ -418,7 +418,7 @@ def test_get_scaling_options(self, containers_service, deployments_endpoint): assert responses.assert_call_count(url, 1) is True @responses.activate - def test_update_scaling_options(self, containers_service, deployments_endpoint): + def test_update_deployment_scaling_options(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/scaling" responses.add( @@ -445,7 +445,7 @@ def test_update_scaling_options(self, containers_service, deployments_endpoint): ) # act - updated_scaling = containers_service.update_scaling_options( + updated_scaling = containers_service.update_deployment_scaling_options( DEPLOYMENT_NAME, scaling_options) # assert @@ -455,7 +455,7 @@ def test_update_scaling_options(self, containers_service, deployments_endpoint): assert responses.assert_call_count(url, 1) is True @responses.activate - def test_get_replicas(self, containers_service, deployments_endpoint): + def test_get_deployment_replicas(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/replicas" responses.add( @@ -466,7 +466,7 @@ def test_get_replicas(self, containers_service, deployments_endpoint): ) # act - replicas = containers_service.get_replicas(DEPLOYMENT_NAME) + replicas = containers_service.get_deployment_replicas(DEPLOYMENT_NAME) # assert assert "replicas" in replicas @@ -475,7 +475,7 @@ def test_get_replicas(self, containers_service, deployments_endpoint): assert responses.assert_call_count(url, 1) is True @responses.activate - def test_purge_queue(self, containers_service, deployments_endpoint): + def test_purge_deployment_queue(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/purge-queue" responses.add( @@ -485,7 +485,7 @@ def test_purge_queue(self, containers_service, deployments_endpoint): ) # act - containers_service.purge_queue(DEPLOYMENT_NAME) + containers_service.purge_deployment_queue(DEPLOYMENT_NAME) # assert assert responses.assert_call_count(url, 1) is True @@ -501,7 +501,7 @@ def test_pause_deployment(self, containers_service, deployments_endpoint): ) # act - containers_service.pause(DEPLOYMENT_NAME) + containers_service.pause_deployment(DEPLOYMENT_NAME) # assert assert responses.assert_call_count(url, 1) is True @@ -517,13 +517,13 @@ def test_resume_deployment(self, containers_service, deployments_endpoint): ) # act - containers_service.resume(DEPLOYMENT_NAME) + containers_service.resume_deployment(DEPLOYMENT_NAME) # assert assert responses.assert_call_count(url, 1) is True @responses.activate - def test_get_environment_variables(self, containers_service, deployments_endpoint): + def test_get_deployment_environment_variables(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" responses.add( @@ -534,7 +534,7 @@ def test_get_environment_variables(self, containers_service, deployments_endpoin ) # act - env_vars = containers_service.get_environment_variables( + env_vars = containers_service.get_deployment_environment_variables( DEPLOYMENT_NAME) # assert @@ -544,7 +544,7 @@ def test_get_environment_variables(self, containers_service, deployments_endpoin assert responses.assert_call_count(url, 1) is True @responses.activate - def test_add_environment_variables(self, containers_service, deployments_endpoint): + def test_add_deployment_environment_variables(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" responses.add( @@ -557,7 +557,7 @@ def test_add_environment_variables(self, containers_service, deployments_endpoin # act env_vars = [{"name": ENV_VAR_NAME, "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] - result = containers_service.add_environment_variables( + result = containers_service.add_deployment_environment_variables( DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) # assert @@ -566,7 +566,7 @@ def test_add_environment_variables(self, containers_service, deployments_endpoin assert responses.assert_call_count(url, 1) is True @responses.activate - def test_update_environment_variables(self, containers_service, deployments_endpoint): + def test_update_deployment_environment_variables(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" responses.add( @@ -579,7 +579,7 @@ def test_update_environment_variables(self, containers_service, deployments_endp # act env_vars = [{"name": ENV_VAR_NAME, "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] - result = containers_service.update_environment_variables( + result = containers_service.update_deployment_environment_variables( DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) # assert @@ -588,7 +588,7 @@ def test_update_environment_variables(self, containers_service, deployments_endp assert responses.assert_call_count(url, 1) is True @responses.activate - def test_delete_environment_variables(self, containers_service, deployments_endpoint): + def test_delete_deployment_environment_variables(self, containers_service, deployments_endpoint): # arrange - add response mock url = f"{deployments_endpoint}/{DEPLOYMENT_NAME}/environment-variables" responses.add( @@ -599,7 +599,7 @@ def test_delete_environment_variables(self, containers_service, deployments_endp ) # act - result = containers_service.delete_environment_variables( + result = containers_service.delete_deployment_environment_variables( DEPLOYMENT_NAME, CONTAINER_NAME, [ENV_VAR_NAME]) # assert From f22ade68c939d08cab5ee69c3bf7548399569101 Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 08:53:23 +0200 Subject: [PATCH 14/24] fixed compute resource parse bug and added example --- datacrunch/containers/containers.py | 6 +- examples/containers/compute_resources.py | 73 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 examples/containers/compute_resources.py diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 706986d..a7b34b4 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -444,7 +444,11 @@ def get_compute_resources(self) -> List[ComputeResource]: :rtype: List[ComputeResource] """ response = self.client.get(SERVERLESS_COMPUTE_RESOURCES_ENDPOINT) - return [ComputeResource.from_dict(resource) for resource in response.json()] + resources = [] + for resource_group in response.json(): + for resource in resource_group: + resources.append(ComputeResource.from_dict(resource)) + return resources def get_secrets(self) -> List[Secret]: """Get all secrets diff --git a/examples/containers/compute_resources.py b/examples/containers/compute_resources.py new file mode 100644 index 0000000..501194d --- /dev/null +++ b/examples/containers/compute_resources.py @@ -0,0 +1,73 @@ +from datacrunch import DataCrunchClient +from typing import List +from datacrunch.containers.containers import ComputeResource + + +def list_all_compute_resources(client: DataCrunchClient) -> List[ComputeResource]: + """List all available compute resources. + + Args: + client (DataCrunchClient): The DataCrunch API client. + + Returns: + List[ComputeResource]: List of all compute resources. + """ + return client.containers.get_compute_resources() + + +def list_available_compute_resources(client: DataCrunchClient) -> List[ComputeResource]: + """List only the available compute resources. + + Args: + client (DataCrunchClient): The DataCrunch API client. + + Returns: + List[ComputeResource]: List of available compute resources. + """ + all_resources = client.containers.get_compute_resources() + return [r for r in all_resources if r.is_available] + + +def list_compute_resources_by_size(client: DataCrunchClient, size: int) -> List[ComputeResource]: + """List compute resources filtered by size. + + Args: + client (DataCrunchClient): The DataCrunch API client. + size (int): The size to filter by. + + Returns: + List[ComputeResource]: List of compute resources with the specified size. + """ + all_resources = client.containers.get_compute_resources() + return [r for r in all_resources if r.size == size] + + +def main(): + # Initialize the client with your credentials + client = DataCrunchClient( + client_id="your_client_id", + client_secret="your_client_secret" + ) + + # Example 1: List all compute resources + print("\nAll compute resources:") + all_resources = list_all_compute_resources(client) + for resource in all_resources: + print( + f"Name: {resource.name}, Size: {resource.size}, Available: {resource.is_available}") + + # Example 2: List available compute resources + print("\nAvailable compute resources:") + available_resources = list_available_compute_resources(client) + for resource in available_resources: + print(f"Name: {resource.name}, Size: {resource.size}") + + # Example 3: List compute resources of size 8 + print("\nCompute resources with size 8:") + size_8_resources = list_compute_resources_by_size(client, 8) + for resource in size_8_resources: + print(f"Name: {resource.name}, Available: {resource.is_available}") + + +if __name__ == "__main__": + main() From f14fb017ec04071bb084111658acb8464bc764b1 Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 08:58:26 +0200 Subject: [PATCH 15/24] fixed test --- tests/unit_tests/containers/test_containers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 22021ca..9bee548 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -613,7 +613,8 @@ def test_get_compute_resources(self, containers_service, compute_resources_endpo responses.add( responses.GET, compute_resources_endpoint, - json=COMPUTE_RESOURCES_DATA, + # Wrap in list to simulate resource groups + json=[COMPUTE_RESOURCES_DATA], status=200 ) From c2107f7ab77d1157775e4cb849d417470ba0223c Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 12:48:25 +0200 Subject: [PATCH 16/24] fix: create_secret is void. added secrets example. rename files --- datacrunch/containers/containers.py | 8 +--- ...ources.py => compute_resources_example.py} | 0 ...ts.py => container_deployments_example.py} | 0 examples/containers/secrets_example.py | 38 +++++++++++++++++++ ...oyment.py => sglang_deployment_example.py} | 0 ...y => update_deployment_scaling_example.py} | 0 6 files changed, 40 insertions(+), 6 deletions(-) rename examples/containers/{compute_resources.py => compute_resources_example.py} (100%) rename examples/containers/{container_deployments.py => container_deployments_example.py} (100%) create mode 100644 examples/containers/secrets_example.py rename examples/containers/{sglang_deployment.py => sglang_deployment_example.py} (100%) rename examples/containers/{update_deployment_scaling.py => update_deployment_scaling_example.py} (100%) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index a7b34b4..6f09d2d 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -459,19 +459,15 @@ def get_secrets(self) -> List[Secret]: response = self.client.get(SECRETS_ENDPOINT) return [Secret.from_dict(secret) for secret in response.json()] - def create_secret(self, name: str, value: str) -> Secret: + def create_secret(self, name: str, value: str) -> None: """Create a new secret :param name: name of the secret :type name: str :param value: value of the secret :type value: str - :return: created secret - :rtype: Secret """ - response = self.client.post( - SECRETS_ENDPOINT, {"name": name, "value": value}) - return Secret.from_dict(response.json()) + self.client.post(SECRETS_ENDPOINT, {"name": name, "value": value}) def delete_secret(self, secret_name: str, force: bool = False) -> None: """Delete a secret diff --git a/examples/containers/compute_resources.py b/examples/containers/compute_resources_example.py similarity index 100% rename from examples/containers/compute_resources.py rename to examples/containers/compute_resources_example.py diff --git a/examples/containers/container_deployments.py b/examples/containers/container_deployments_example.py similarity index 100% rename from examples/containers/container_deployments.py rename to examples/containers/container_deployments_example.py diff --git a/examples/containers/secrets_example.py b/examples/containers/secrets_example.py new file mode 100644 index 0000000..ed12d65 --- /dev/null +++ b/examples/containers/secrets_example.py @@ -0,0 +1,38 @@ +import os +from datacrunch import DataCrunchClient + +# Environment variables +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') + +# Initialize DataCrunch client +datacrunch_client = DataCrunchClient(client_id=DATACRUNCH_CLIENT_ID, + client_secret=DATACRUNCH_CLIENT_SECRET) + +# List all secrets +secrets = datacrunch_client.containers.get_secrets() +print("Available secrets:") +for secret in secrets: + print(f"- {secret.name} (created at: {secret.created_at})") + +# Create a new secret +secret_name = "my-api-key" +secret_value = "super-secret-value" +datacrunch_client.containers.create_secret( + name=secret_name, + value=secret_value +) +print(f"\nCreated new secret: {secret_name}") + +# Delete a secret (with force=False by default) +datacrunch_client.containers.delete_secret(secret_name) +print(f"\nDeleted secret: {secret_name}") + +# Delete a secret with force=True (will delete even if secret is in use) +secret_name = "another-secret" +datacrunch_client.containers.create_secret( + name=secret_name, + value=secret_value +) +datacrunch_client.containers.delete_secret(secret_name, force=True) +print(f"\nForce deleted secret: {secret_name}") diff --git a/examples/containers/sglang_deployment.py b/examples/containers/sglang_deployment_example.py similarity index 100% rename from examples/containers/sglang_deployment.py rename to examples/containers/sglang_deployment_example.py diff --git a/examples/containers/update_deployment_scaling.py b/examples/containers/update_deployment_scaling_example.py similarity index 100% rename from examples/containers/update_deployment_scaling.py rename to examples/containers/update_deployment_scaling_example.py From e350d7ec63ba128733d74914f46fe19d2f532596 Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 12:57:10 +0200 Subject: [PATCH 17/24] fixed test --- tests/unit_tests/containers/test_containers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 9bee548..06dfbb6 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -1,5 +1,6 @@ import pytest import responses # https://github.com/getsentry/responses +from responses import matchers from datacrunch.containers.containers import ( CONTAINER_DEPLOYMENTS_ENDPOINT, @@ -657,16 +658,19 @@ def test_create_secret(self, containers_service, secrets_endpoint): responses.add( responses.POST, secrets_endpoint, - json=SECRETS_DATA[0], - status=200 + status=201, + match=[ + matchers.json_params_matcher( + # The test will now fail if the request body doesn't match the expected JSON structure + {"name": SECRET_NAME, "value": SECRET_VALUE} + ) + ] ) # act - secret = containers_service.create_secret(SECRET_NAME, SECRET_VALUE) + containers_service.create_secret(SECRET_NAME, SECRET_VALUE) # assert - assert type(secret) == Secret - assert secret.name == SECRET_NAME assert responses.assert_call_count(secrets_endpoint, 1) is True @responses.activate From 80f732966ca1a511444520ef5a7fb42d6d981697 Mon Sep 17 00:00:00 2001 From: Tamir Date: Wed, 19 Mar 2025 13:05:01 +0200 Subject: [PATCH 18/24] changelog entry --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ba9a4d9..2140dfd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog ========= +* Added support for containers + v1.7.3 (2025-03-07) ------------------- From d11c95c339f5181b68484ea3a33a1c8c54f619f9 Mon Sep 17 00:00:00 2001 From: Tamir Date: Thu, 20 Mar 2025 14:09:40 +0200 Subject: [PATCH 19/24] return proper types, add env var example file, exports --- datacrunch/containers/__init__.py | 24 +++++ datacrunch/containers/containers.py | 72 +++++++++---- .../environment_variables_example.py | 100 ++++++++++++++++++ 3 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 examples/containers/environment_variables_example.py diff --git a/datacrunch/containers/__init__.py b/datacrunch/containers/__init__.py index e69de29..2e11100 100644 --- a/datacrunch/containers/__init__.py +++ b/datacrunch/containers/__init__.py @@ -0,0 +1,24 @@ +from .containers import ( + EnvVar, + EnvVarType, + ContainerRegistryType, + ContainerDeploymentStatus, + HealthcheckSettings, + EntrypointOverridesSettings, + VolumeMount, + VolumeMountType, + Container, + ContainerRegistryCredentials, + ContainerRegistrySettings, + ComputeResource, + ScalingPolicy, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, + ScalingTriggers, + ScalingOptions, + Deployment, + ReplicaInfo, + Secret, + RegistryCredential, + ContainersService, +) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 6f09d2d..3b67f63 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -332,17 +332,17 @@ def update_deployment_scaling_options(self, deployment_name: str, scaling_option ) return ScalingOptions.from_dict(response.json()) - def get_deployment_replicas(self, deployment_name: str) -> Dict: + def get_deployment_replicas(self, deployment_name: str) -> ReplicaInfo: """Get deployment replicas :param deployment_name: name of the deployment :type deployment_name: str :return: replicas information - :rtype: Dict + :rtype: ReplicaInfo """ response = self.client.get( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/replicas") - return response.json() + return [ReplicaInfo.from_dict(replica) for replica in response.json()["list"]] def purge_deployment_queue(self, deployment_name: str) -> None: """Purge deployment queue @@ -371,19 +371,25 @@ def resume_deployment(self, deployment_name: str) -> None: self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/resume") - def get_deployment_environment_variables(self, deployment_name: str) -> Dict: + def get_deployment_environment_variables(self, deployment_name: str) -> Dict[str, List[EnvVar]]: """Get deployment environment variables :param deployment_name: name of the deployment :type deployment_name: str - :return: environment variables - :rtype: Dict + :return: dictionary mapping container names to their environment variables + :rtype: Dict[str, List[EnvVar]] """ response = self.client.get( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables") - return response.json() - - def add_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + result = {} + for item in response.json(): + container_name = item["container_name"] + env_vars = item["env"] + result[container_name] = [EnvVar.from_dict( + env_var) for env_var in env_vars] + return result + + def add_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[EnvVar]) -> Dict[str, List[EnvVar]]: """Add environment variables to a container :param deployment_name: name of the deployment @@ -391,17 +397,24 @@ def add_deployment_environment_variables(self, deployment_name: str, container_n :param container_name: name of the container :type container_name: str :param env_vars: environment variables to add - :type env_vars: List[Dict] + :type env_vars: List[EnvVar] :return: updated environment variables - :rtype: Dict + :rtype: Dict[str, List[EnvVar]] """ response = self.client.post( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", - {"container_name": container_name, "env": env_vars} + {"container_name": container_name, "env": [ + env_var.to_dict() for env_var in env_vars]} ) - return response.json() - - def update_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[Dict]) -> Dict: + result = {} + for item in response.json(): + container_name = item["container_name"] + env_vars = item["env"] + result[container_name] = [EnvVar.from_dict( + env_var) for env_var in env_vars] + return result + + def update_deployment_environment_variables(self, deployment_name: str, container_name: str, env_vars: List[EnvVar]) -> Dict[str, List[EnvVar]]: """Update environment variables of a container :param deployment_name: name of the deployment @@ -409,17 +422,24 @@ def update_deployment_environment_variables(self, deployment_name: str, containe :param container_name: name of the container :type container_name: str :param env_vars: updated environment variables - :type env_vars: List[Dict] + :type env_vars: List[EnvVar] :return: updated environment variables - :rtype: Dict + :rtype: Dict[str, List[EnvVar]] """ response = self.client.patch( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", - {"container_name": container_name, "env": env_vars} + {"container_name": container_name, "env": [ + env_var.to_dict() for env_var in env_vars]} ) - return response.json() - - def delete_deployment_environment_variables(self, deployment_name: str, container_name: str, env_var_names: List[str]) -> Dict: + result = {} + item = response.json() + container_name = item["container_name"] + env_vars = item["env"] + result[container_name] = [EnvVar.from_dict( + env_var) for env_var in env_vars] + return result + + def delete_deployment_environment_variables(self, deployment_name: str, container_name: str, env_var_names: List[str]) -> Dict[str, List[EnvVar]]: """Delete environment variables from a container :param deployment_name: name of the deployment @@ -429,13 +449,19 @@ def delete_deployment_environment_variables(self, deployment_name: str, containe :param env_var_names: names of environment variables to delete :type env_var_names: List[str] :return: remaining environment variables - :rtype: Dict + :rtype: Dict[str, List[EnvVar]] """ response = self.client.delete( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", {"container_name": container_name, "env": env_var_names} ) - return response.json() + result = {} + for item in response.json(): + container_name = item["container_name"] + env_vars = item["env"] + result[container_name] = [EnvVar.from_dict( + env_var) for env_var in env_vars] + return result def get_compute_resources(self) -> List[ComputeResource]: """Get available compute resources diff --git a/examples/containers/environment_variables_example.py b/examples/containers/environment_variables_example.py new file mode 100644 index 0000000..3a98220 --- /dev/null +++ b/examples/containers/environment_variables_example.py @@ -0,0 +1,100 @@ +""" +This example demonstrates how to manage environment variables for container deployments. +It shows how to: +1. Get environment variables for a deployment +2. Add new environment variables to a container +3. Update existing environment variables +4. Delete environment variables +""" + +import os +from datacrunch.containers import EnvVar, EnvVarType +from datacrunch import DataCrunchClient +from typing import Dict, List + +DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') +DATACRUNCH_CLIENT_SECRET = os.environ.get('DATACRUNCH_CLIENT_SECRET') + +# Initialize DataCrunch client +datacrunch_client = DataCrunchClient(client_id=DATACRUNCH_CLIENT_ID, + client_secret=DATACRUNCH_CLIENT_SECRET) + +# Example deployment and container names +DEPLOYMENT_NAME = "my-deployment" +CONTAINER_NAME = "main" + + +def print_env_vars(env_vars: Dict[str, List[EnvVar]]) -> None: + """Helper function to print environment variables""" + print("\nCurrent environment variables:") + for container_name, vars in env_vars.items(): + print(f"\nContainer: {container_name}") + for var in vars: + print(f" {var.name}: {var.value_or_reference_to_secret} ({var.type})") + + +def main(): + # First, let's get the current environment variables + print("Getting current environment variables...") + env_vars = datacrunch_client.containers.get_deployment_environment_variables( + DEPLOYMENT_NAME) + print_env_vars(env_vars) + + # Create a new secret + secret_name = "my-secret-key" + datacrunch_client.containers.create_secret( + secret_name, + "my-secret-value" + ) + + # Add new environment variables + print("\nAdding new environment variables...") + new_env_vars = [ + EnvVar( + name="API_KEY", + value_or_reference_to_secret=secret_name, + type=EnvVarType.SECRET + ), + EnvVar( + name="DEBUG", + value_or_reference_to_secret="true", + type=EnvVarType.PLAIN + ) + ] + + env_vars = datacrunch_client.containers.add_deployment_environment_variables( + deployment_name=DEPLOYMENT_NAME, + container_name=CONTAINER_NAME, + env_vars=new_env_vars + ) + print_env_vars(env_vars) + + # Update existing environment variables + print("\nUpdating environment variables...") + updated_env_vars = [ + EnvVar( + name="DEBUG", + value_or_reference_to_secret="false", + type=EnvVarType.PLAIN + ), + ] + + env_vars = datacrunch_client.containers.update_deployment_environment_variables( + deployment_name=DEPLOYMENT_NAME, + container_name=CONTAINER_NAME, + env_vars=updated_env_vars + ) + print_env_vars(env_vars) + + # Delete environment variables + print("\nDeleting environment variables...") + env_vars = datacrunch_client.containers.delete_deployment_environment_variables( + deployment_name=DEPLOYMENT_NAME, + container_name=CONTAINER_NAME, + env_var_names=["DEBUG"] + ) + print_env_vars(env_vars) + + +if __name__ == "__main__": + main() From 46508b2a7ff073ace92c35515e7eb9289d381213 Mon Sep 17 00:00:00 2001 From: Tamir Date: Thu, 20 Mar 2025 14:25:08 +0200 Subject: [PATCH 20/24] added and fixed docstrings --- datacrunch/containers/containers.py | 100 +++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 3b67f63..8762569 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -45,6 +45,12 @@ class ContainerDeploymentStatus(str, Enum): @dataclass_json @dataclass class HealthcheckSettings: + """Settings for container health checking. + + :param enabled: Whether health checking is enabled + :param port: Port number to perform health check on + :param path: HTTP path to perform health check on + """ enabled: bool port: Optional[int] = None path: Optional[str] = None @@ -53,6 +59,12 @@ class HealthcheckSettings: @dataclass_json @dataclass class EntrypointOverridesSettings: + """Settings for overriding container entrypoint and command. + + :param enabled: Whether entrypoint overrides are enabled + :param entrypoint: List of strings forming the entrypoint command + :param cmd: List of strings forming the command arguments + """ enabled: bool entrypoint: Optional[List[str]] = None cmd: Optional[List[str]] = None @@ -61,6 +73,12 @@ class EntrypointOverridesSettings: @dataclass_json @dataclass class EnvVar: + """Environment variable configuration for containers. + + :param name: Name of the environment variable + :param value_or_reference_to_secret: Direct value or reference to a secret + :param type: Type of the environment variable + """ name: str value_or_reference_to_secret: str type: EnvVarType @@ -69,6 +87,11 @@ class EnvVar: @dataclass_json @dataclass class VolumeMount: + """Volume mount configuration for containers. + + :param type: Type of volume mount + :param mount_path: Path where the volume should be mounted in the container + """ type: VolumeMountType mount_path: str @@ -76,6 +99,16 @@ class VolumeMount: @dataclass_json @dataclass class Container: + """Container configuration for deployments. + + :param name: Name of the container + :param image: Container image to use + :param exposed_port: Port to expose from the container + :param healthcheck: Optional health check configuration + :param entrypoint_overrides: Optional entrypoint override settings + :param env: Optional list of environment variables + :param volume_mounts: Optional list of volume mounts + """ name: str image: str exposed_port: int @@ -88,12 +121,21 @@ class Container: @dataclass_json @dataclass class ContainerRegistryCredentials: + """Credentials for accessing a container registry. + + :param name: Name of the credentials + """ name: str @dataclass_json @dataclass class ContainerRegistrySettings: + """Settings for container registry access. + + :param is_private: Whether the registry is private + :param credentials: Optional credentials for accessing private registry + """ is_private: bool credentials: Optional[ContainerRegistryCredentials] = None @@ -101,6 +143,12 @@ class ContainerRegistrySettings: @dataclass_json @dataclass class ComputeResource: + """Compute resource configuration. + + :param name: Name of the compute resource + :param size: Size of the compute resource + :param is_available: Whether the compute resource is currently available + """ name: str size: int # Made optional since it's only used in API responses @@ -110,18 +158,31 @@ class ComputeResource: @dataclass_json @dataclass class ScalingPolicy: + """Policy for controlling scaling behavior. + + :param delay_seconds: Number of seconds to wait before applying scaling action + """ delay_seconds: int @dataclass_json @dataclass class QueueLoadScalingTrigger: + """Trigger for scaling based on queue load. + + :param threshold: Queue load threshold that triggers scaling + """ threshold: float @dataclass_json @dataclass class UtilizationScalingTrigger: + """Trigger for scaling based on resource utilization. + + :param enabled: Whether this trigger is enabled + :param threshold: Utilization threshold that triggers scaling + """ enabled: bool threshold: Optional[float] = None @@ -129,6 +190,12 @@ class UtilizationScalingTrigger: @dataclass_json @dataclass class ScalingTriggers: + """Collection of triggers that can cause scaling actions. + + :param queue_load: Optional trigger based on queue load + :param cpu_utilization: Optional trigger based on CPU utilization + :param gpu_utilization: Optional trigger based on GPU utilization + """ queue_load: Optional[QueueLoadScalingTrigger] = None cpu_utilization: Optional[UtilizationScalingTrigger] = None gpu_utilization: Optional[UtilizationScalingTrigger] = None @@ -137,6 +204,16 @@ class ScalingTriggers: @dataclass_json @dataclass class ScalingOptions: + """Configuration for automatic scaling behavior. + + :param min_replica_count: Minimum number of replicas to maintain + :param max_replica_count: Maximum number of replicas allowed + :param scale_down_policy: Policy for scaling down replicas + :param scale_up_policy: Policy for scaling up replicas + :param queue_message_ttl_seconds: Time-to-live for queue messages in seconds + :param concurrent_requests_per_replica: Number of concurrent requests each replica can handle + :param scaling_triggers: Configuration for various scaling triggers + """ min_replica_count: int max_replica_count: int scale_down_policy: ScalingPolicy @@ -149,6 +226,17 @@ class ScalingOptions: @dataclass_json(undefined=Undefined.EXCLUDE) @dataclass class Deployment: + """Configuration for a container deployment. + + :param name: Name of the deployment + :param container_registry_settings: Settings for accessing container registry + :param containers: List of containers in the deployment + :param compute: Compute resource configuration + :param is_spot: Whether is spot deployment + :param endpoint_base_url: Optional base URL for the deployment endpoint + :param scaling: Optional scaling configuration + :param created_at: Timestamp when the deployment was created + """ name: str container_registry_settings: ContainerRegistrySettings containers: List[Container] @@ -170,6 +258,12 @@ class Deployment: @dataclass_json @dataclass class ReplicaInfo: + """Information about a deployment replica. + + :param id: Unique identifier of the replica + :param status: Current status of the replica + :param started_at: Timestamp when the replica was started + """ id: str status: str started_at: datetime = field( @@ -332,13 +426,13 @@ def update_deployment_scaling_options(self, deployment_name: str, scaling_option ) return ScalingOptions.from_dict(response.json()) - def get_deployment_replicas(self, deployment_name: str) -> ReplicaInfo: + def get_deployment_replicas(self, deployment_name: str) -> List[ReplicaInfo]: """Get deployment replicas :param deployment_name: name of the deployment :type deployment_name: str - :return: replicas information - :rtype: ReplicaInfo + :return: list of replicas information + :rtype: List[ReplicaInfo] """ response = self.client.get( f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/replicas") From 8d665b4913c2678ead52af83f5662e5dbfce222c Mon Sep 17 00:00:00 2001 From: Tamir Date: Thu, 20 Mar 2025 14:51:52 +0200 Subject: [PATCH 21/24] refactored datetime to str, removed marshmallow --- datacrunch/containers/containers.py | 36 ++++------------------------- requirements.txt | 1 - 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index 8762569..cbc0344 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -1,8 +1,6 @@ from dataclasses import dataclass, field from dataclasses_json import dataclass_json, config, Undefined # type: ignore from typing import List, Optional, Dict -from datetime import datetime -from marshmallow import fields from enum import Enum @@ -244,15 +242,7 @@ class Deployment: is_spot: bool = False endpoint_base_url: Optional[str] = None scaling: Optional[ScalingOptions] = None - created_at: Optional[datetime] = field( - default=None, - metadata=config( - encoder=lambda x: x.isoformat() if x is not None else None, - decoder=lambda x: datetime.fromisoformat( - x) if x is not None else None, - mm_field=fields.DateTime(format='iso') - ) - ) + created_at: Optional[str] = None @dataclass_json @@ -266,13 +256,7 @@ class ReplicaInfo: """ id: str status: str - started_at: datetime = field( - metadata=config( - encoder=datetime.isoformat, - decoder=datetime.fromisoformat, - mm_field=fields.DateTime(format='iso') - ) - ) + started_at: str @dataclass_json @@ -280,13 +264,7 @@ class ReplicaInfo: class Secret: """A secret model class""" name: str - created_at: datetime = field( - metadata=config( - encoder=datetime.isoformat, - decoder=datetime.fromisoformat, - mm_field=fields.DateTime(format='iso') - ) - ) + created_at: str @dataclass_json @@ -294,13 +272,7 @@ class Secret: class RegistryCredential: """A container registry credential model class""" name: str - created_at: datetime = field( - metadata=config( - encoder=datetime.isoformat, - decoder=datetime.fromisoformat, - mm_field=fields.DateTime(format='iso') - ) - ) + created_at: str class ContainersService: diff --git a/requirements.txt b/requirements.txt index c92aa13..e96c9af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ certifi==2025.1.31 charset-normalizer==3.4.1 dataclasses-json==0.6.7 idna==3.10 -marshmallow==3.26.1 mypy-extensions==1.0.0 packaging==24.2 requests==2.32.3 From 40904ee97358667f152ed36afc1cc52dc11c1702 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 21 Mar 2025 08:09:08 +0200 Subject: [PATCH 22/24] use dedicated classes as input for add registry credentials method --- datacrunch/containers/__init__.py | 6 + datacrunch/containers/containers.py | 145 ++++++++++-------- .../registry_credentials_example.py | 28 ++-- .../unit_tests/containers/test_containers.py | 47 +++--- 4 files changed, 124 insertions(+), 102 deletions(-) diff --git a/datacrunch/containers/__init__.py b/datacrunch/containers/__init__.py index 2e11100..5036b1b 100644 --- a/datacrunch/containers/__init__.py +++ b/datacrunch/containers/__init__.py @@ -21,4 +21,10 @@ Secret, RegistryCredential, ContainersService, + BaseRegistryCredentials, + DockerHubCredentials, + GithubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials, ) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py index cbc0344..9cb2283 100644 --- a/datacrunch/containers/containers.py +++ b/datacrunch/containers/containers.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass, field -from dataclasses_json import dataclass_json, config, Undefined # type: ignore +from dataclasses import dataclass +from dataclasses_json import dataclass_json, Undefined # type: ignore from typing import List, Optional, Dict from enum import Enum @@ -275,6 +275,79 @@ class RegistryCredential: created_at: str +@dataclass_json +@dataclass +class BaseRegistryCredentials: + """Base class for registry credentials""" + name: str + type: ContainerRegistryType + + +@dataclass_json +@dataclass +class DockerHubCredentials(BaseRegistryCredentials): + """Credentials for DockerHub registry""" + username: str + access_token: str + + def __init__(self, name: str, username: str, access_token: str): + super().__init__(name=name, type=ContainerRegistryType.DOCKERHUB) + self.username = username + self.access_token = access_token + + +@dataclass_json +@dataclass +class GithubCredentials(BaseRegistryCredentials): + """Credentials for GitHub Container Registry""" + username: str + access_token: str + + def __init__(self, name: str, username: str, access_token: str): + super().__init__(name=name, type=ContainerRegistryType.GITHUB) + self.username = username + self.access_token = access_token + + +@dataclass_json +@dataclass +class GCRCredentials(BaseRegistryCredentials): + """Credentials for Google Container Registry""" + service_account_key: str + + def __init__(self, name: str, service_account_key: str): + super().__init__(name=name, type=ContainerRegistryType.GCR) + self.service_account_key = service_account_key + + +@dataclass_json +@dataclass +class AWSECRCredentials(BaseRegistryCredentials): + """Credentials for AWS Elastic Container Registry""" + access_key_id: str + secret_access_key: str + region: str + ecr_repo: str + + def __init__(self, name: str, access_key_id: str, secret_access_key: str, region: str, ecr_repo: str): + super().__init__(name=name, type=ContainerRegistryType.AWS_ECR) + self.access_key_id = access_key_id + self.secret_access_key = secret_access_key + self.region = region + self.ecr_repo = ecr_repo + + +@dataclass_json +@dataclass +class CustomRegistryCredentials(BaseRegistryCredentials): + """Credentials for custom container registries""" + docker_config_json: str + + def __init__(self, name: str, docker_config_json: str): + super().__init__(name=name, type=ContainerRegistryType.CUSTOM) + self.docker_config_json = docker_config_json + + class ContainersService: """Service for managing container deployments""" @@ -581,73 +654,13 @@ def get_registry_credentials(self) -> List[RegistryCredential]: response = self.client.get(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT) return [RegistryCredential.from_dict(credential) for credential in response.json()] - def add_registry_credentials( - self, - name: str, - registry_type: ContainerRegistryType, - username: str = None, - access_token: str = None, - service_account_key: str = None, - docker_config_json: str = None, - access_key_id: str = None, - secret_access_key: str = None, - region: str = None, - ecr_repo: str = None - ) -> None: + def add_registry_credentials(self, credentials: BaseRegistryCredentials) -> None: """Add registry credentials - :param name: name of the credentials - :type name: str - :param registry_type: type of registry (e.g. ContainerRegistryType.DOCKERHUB) - :type registry_type: ContainerRegistryType - :param username: registry username (required for DOCKERHUB and GITHUB) - :type username: str - :param access_token: registry access token (required for DOCKERHUB and GITHUB) - :type access_token: str - :param service_account_key: service account key JSON string (required for GCR) - :type service_account_key: str - :param docker_config_json: docker config JSON string (required for CUSTOM) - :type docker_config_json: str - :param access_key_id: AWS access key ID (required for AWS_ECR) - :type access_key_id: str - :param secret_access_key: AWS secret access key (required for AWS_ECR) - :type secret_access_key: str - :param region: AWS region (required for AWS_ECR) - :type region: str - :param ecr_repo: ECR repository URL (required for AWS_ECR) - :type ecr_repo: str + :param credentials: Registry credentials object + :type credentials: BaseRegistryCredentials """ - data = { - "name": name, - "type": registry_type.value - } - - # Add specific parameters based on registry type - if registry_type == ContainerRegistryType.DOCKERHUB or registry_type == ContainerRegistryType.GITHUB: - if not username or not access_token: - raise ValueError( - f"Username and access_token are required for {registry_type.value} registry type") - data["username"] = username - data["access_token"] = access_token - elif registry_type == ContainerRegistryType.GCR: - if not service_account_key: - raise ValueError( - "service_account_key is required for GCR registry type") - data["service_account_key"] = service_account_key - elif registry_type == ContainerRegistryType.AWS_ECR: - if not all([access_key_id, secret_access_key, region, ecr_repo]): - raise ValueError( - "access_key_id, secret_access_key, region, and ecr_repo are required for AWS_ECR registry type") - data["access_key_id"] = access_key_id - data["secret_access_key"] = secret_access_key - data["region"] = region - data["ecr_repo"] = ecr_repo - elif registry_type == ContainerRegistryType.CUSTOM: - if not docker_config_json: - raise ValueError( - "docker_config_json is required for CUSTOM registry type") - data["docker_config_json"] = docker_config_json - + data = credentials.to_dict() self.client.post(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, data) def delete_registry_credentials(self, credentials_name: str) -> None: diff --git a/examples/containers/registry_credentials_example.py b/examples/containers/registry_credentials_example.py index 9820313..6c20f94 100644 --- a/examples/containers/registry_credentials_example.py +++ b/examples/containers/registry_credentials_example.py @@ -1,6 +1,12 @@ import os from datacrunch import DataCrunchClient -from datacrunch.containers.containers import ContainerRegistryType +from datacrunch.containers import ( + DockerHubCredentials, + GithubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials +) # Environment variables DATACRUNCH_CLIENT_ID = os.environ.get('DATACRUNCH_CLIENT_ID') @@ -11,21 +17,21 @@ client_secret=DATACRUNCH_CLIENT_SECRET) # Example 1: DockerHub Credentials -datacrunch_client.containers.add_registry_credentials( +dockerhub_creds = DockerHubCredentials( name="my-dockerhub-creds", - registry_type=ContainerRegistryType.DOCKERHUB, username="your-dockerhub-username", access_token="your-dockerhub-access-token" ) +datacrunch_client.containers.add_registry_credentials(dockerhub_creds) print("Created DockerHub credentials") # Example 2: GitHub Container Registry Credentials -datacrunch_client.containers.add_registry_credentials( +github_creds = GithubCredentials( name="my-github-creds", - registry_type=ContainerRegistryType.GITHUB, username="your-github-username", access_token="your-github-token" ) +datacrunch_client.containers.add_registry_credentials(github_creds) print("Created GitHub credentials") # Example 3: Google Container Registry (GCR) Credentials @@ -43,22 +49,22 @@ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project.iam.gserviceaccount.com" }""" -datacrunch_client.containers.add_registry_credentials( +gcr_creds = GCRCredentials( name="my-gcr-creds", - registry_type=ContainerRegistryType.GCR, service_account_key=gcr_service_account_key ) +datacrunch_client.containers.add_registry_credentials(gcr_creds) print("Created GCR credentials") # Example 4: AWS ECR Credentials -datacrunch_client.containers.add_registry_credentials( +aws_creds = AWSECRCredentials( name="my-aws-ecr-creds", - registry_type=ContainerRegistryType.AWS_ECR, access_key_id="AKIAEXAMPLE123456", secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", region="eu-north-1", ecr_repo="887841266746.dkr.ecr.eu-north-1.amazonaws.com" ) +datacrunch_client.containers.add_registry_credentials(aws_creds) print("Created AWS ECR credentials") # Example 5: Custom Registry Credentials @@ -70,11 +76,11 @@ } }""" -datacrunch_client.containers.add_registry_credentials( +custom_creds = CustomRegistryCredentials( name="my-custom-registry-creds", - registry_type=ContainerRegistryType.CUSTOM, docker_config_json=custom_docker_config ) +datacrunch_client.containers.add_registry_credentials(custom_creds) print("Created Custom registry credentials") # Delete all registry credentials diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 06dfbb6..e1d9c0c 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -27,6 +27,10 @@ ScalingTriggers, QueueLoadScalingTrigger, UtilizationScalingTrigger, + DockerHubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials, ) from datacrunch.exceptions import APIException @@ -740,27 +744,17 @@ def test_add_registry_credentials(self, containers_service, registry_credentials ) # act - containers_service.add_registry_credentials( - REGISTRY_CREDENTIAL_NAME, - ContainerRegistryType.DOCKERHUB, - "username", - "token" + creds = DockerHubCredentials( + name=REGISTRY_CREDENTIAL_NAME, + username="username", + access_token="token" ) + containers_service.add_registry_credentials(creds) # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - - @responses.activate - def test_add_registry_credentials_validation_error(self, containers_service): - # act & assert - with pytest.raises(ValueError) as excinfo: - containers_service.add_registry_credentials( - REGISTRY_CREDENTIAL_NAME, - ContainerRegistryType.DOCKERHUB, - # Missing username and token - ) - assert "Username and access_token are required" in str(excinfo.value) + assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "dockerhub", "username": "username", "access_token": "token"}' @responses.activate def test_add_registry_credentials_gcr(self, containers_service, registry_credentials_endpoint): @@ -773,15 +767,16 @@ def test_add_registry_credentials_gcr(self, containers_service, registry_credent # act service_account_key = '{"key": "value"}' - containers_service.add_registry_credentials( - REGISTRY_CREDENTIAL_NAME, - ContainerRegistryType.GCR, + creds = GCRCredentials( + name=REGISTRY_CREDENTIAL_NAME, service_account_key=service_account_key ) + containers_service.add_registry_credentials(creds) # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True + assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "gcr", "service_account_key": {"key": "value"}}' @responses.activate def test_add_registry_credentials_aws_ecr(self, containers_service, registry_credentials_endpoint): @@ -793,18 +788,19 @@ def test_add_registry_credentials_aws_ecr(self, containers_service, registry_cre ) # act - containers_service.add_registry_credentials( - REGISTRY_CREDENTIAL_NAME, - ContainerRegistryType.AWS_ECR, + creds = AWSECRCredentials( + name=REGISTRY_CREDENTIAL_NAME, access_key_id="test-key", secret_access_key="test-secret", region="us-west-2", ecr_repo="test.ecr.aws.com" ) + containers_service.add_registry_credentials(creds) # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True + assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "aws-ecr", "access_key_id": "test-key", "secret_access_key": "test-secret", "region": "us-west-2", "ecr_repo": "test.ecr.aws.com"}' @responses.activate def test_add_registry_credentials_custom(self, containers_service, registry_credentials_endpoint): @@ -817,15 +813,16 @@ def test_add_registry_credentials_custom(self, containers_service, registry_cred # act docker_config = '{"auths": {"registry.example.com": {"auth": "base64-encoded"}}}' - containers_service.add_registry_credentials( - REGISTRY_CREDENTIAL_NAME, - ContainerRegistryType.CUSTOM, + creds = CustomRegistryCredentials( + name=REGISTRY_CREDENTIAL_NAME, docker_config_json=docker_config ) + containers_service.add_registry_credentials(creds) # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True + assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "custom", "docker_config_json": {"auths": {"registry.example.com": {"auth": "base64-encoded"}}}}' @responses.activate def test_delete_registry_credentials(self, containers_service, registry_credentials_endpoint): From 2121f0634b2f033c77ecdbdc490544ea371993f1 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 21 Mar 2025 08:57:44 +0200 Subject: [PATCH 23/24] updated tests --- .../unit_tests/containers/test_containers.py | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index e1d9c0c..493e984 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -10,7 +10,6 @@ Container, ContainerDeploymentStatus, ContainerRegistrySettings, - ContainerRegistryType, ContainersService, Deployment, EnvVar, @@ -31,6 +30,7 @@ GCRCredentials, AWSECRCredentials, CustomRegistryCredentials, + ReplicaInfo, ) from datacrunch.exceptions import APIException @@ -151,7 +151,7 @@ # Sample replicas data REPLICAS_DATA = { - "replicas": [ + "list": [ { "id": "replica-1", "status": "running", @@ -160,17 +160,18 @@ ] } + # Sample environment variables data -ENV_VARS_DATA = { +ENV_VARS_DATA = [{ "container_name": CONTAINER_NAME, "env": [ { - "name": "ENV_VAR1", - "value_or_reference_to_secret": "value1", + "name": ENV_VAR_NAME, + "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain" } ] -} +}] class TestContainersService: @@ -474,9 +475,9 @@ def test_get_deployment_replicas(self, containers_service, deployments_endpoint) replicas = containers_service.get_deployment_replicas(DEPLOYMENT_NAME) # assert - assert "replicas" in replicas - assert len(replicas["replicas"]) == 1 - assert replicas["replicas"][0]["id"] == "replica-1" + assert len(replicas) == 1 + assert replicas[0] == ReplicaInfo( + "replica-1", "running", "2023-01-01T00:00:00+00:00") assert responses.assert_call_count(url, 1) is True @responses.activate @@ -543,9 +544,11 @@ def test_get_deployment_environment_variables(self, containers_service, deployme DEPLOYMENT_NAME) # assert - assert env_vars["container_name"] == CONTAINER_NAME - assert len(env_vars["env"]) == 1 - assert env_vars["env"][0]["name"] == "ENV_VAR1" + assert env_vars[CONTAINER_NAME] == [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )] assert responses.assert_call_count(url, 1) is True @responses.activate @@ -560,14 +563,20 @@ def test_add_deployment_environment_variables(self, containers_service, deployme ) # act - env_vars = [{"name": ENV_VAR_NAME, - "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] + env_vars = [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )] result = containers_service.add_deployment_environment_variables( DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) # assert - assert result["container_name"] == CONTAINER_NAME - assert len(result["env"]) == 1 + assert result[CONTAINER_NAME] == [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )] assert responses.assert_call_count(url, 1) is True @responses.activate @@ -577,19 +586,25 @@ def test_update_deployment_environment_variables(self, containers_service, deplo responses.add( responses.PATCH, url, - json=ENV_VARS_DATA, + json=ENV_VARS_DATA[0], status=200 ) # act - env_vars = [{"name": ENV_VAR_NAME, - "value_or_reference_to_secret": ENV_VAR_VALUE, "type": "plain"}] + env_vars = [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )] result = containers_service.update_deployment_environment_variables( DEPLOYMENT_NAME, CONTAINER_NAME, env_vars) # assert - assert result["container_name"] == CONTAINER_NAME - assert len(result["env"]) == 1 + assert result[CONTAINER_NAME] == [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )] assert responses.assert_call_count(url, 1) is True @responses.activate @@ -599,7 +614,7 @@ def test_delete_deployment_environment_variables(self, containers_service, deplo responses.add( responses.DELETE, url, - json={"container_name": CONTAINER_NAME, "env": []}, + json=[], # remaining env variables should be empty after deletion status=200 ) @@ -608,8 +623,7 @@ def test_delete_deployment_environment_variables(self, containers_service, deplo DEPLOYMENT_NAME, CONTAINER_NAME, [ENV_VAR_NAME]) # assert - assert result["container_name"] == CONTAINER_NAME - assert len(result["env"]) == 0 + assert len(result) == 0 assert responses.assert_call_count(url, 1) is True @responses.activate @@ -736,6 +750,8 @@ def test_get_registry_credentials(self, containers_service, registry_credentials @responses.activate def test_add_registry_credentials(self, containers_service, registry_credentials_endpoint): + USERNAME = "username" + ACCESS_TOKEN = "token" # arrange - add response mock responses.add( responses.POST, @@ -746,15 +762,16 @@ def test_add_registry_credentials(self, containers_service, registry_credentials # act creds = DockerHubCredentials( name=REGISTRY_CREDENTIAL_NAME, - username="username", - access_token="token" + username=USERNAME, + access_token=ACCESS_TOKEN ) containers_service.add_registry_credentials(creds) # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "dockerhub", "username": "username", "access_token": "token"}' + assert responses.calls[0].request.body.decode( + 'utf-8') == '{"name": "test-credential", "type": "dockerhub", "username": "username", "access_token": "token"}' @responses.activate def test_add_registry_credentials_gcr(self, containers_service, registry_credentials_endpoint): @@ -776,7 +793,8 @@ def test_add_registry_credentials_gcr(self, containers_service, registry_credent # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "gcr", "service_account_key": {"key": "value"}}' + assert responses.calls[0].request.body.decode( + 'utf-8') == '{"name": "test-credential", "type": "gcr", "service_account_key": "{\\"key\\": \\"value\\"}"}' @responses.activate def test_add_registry_credentials_aws_ecr(self, containers_service, registry_credentials_endpoint): @@ -800,7 +818,8 @@ def test_add_registry_credentials_aws_ecr(self, containers_service, registry_cre # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "aws-ecr", "access_key_id": "test-key", "secret_access_key": "test-secret", "region": "us-west-2", "ecr_repo": "test.ecr.aws.com"}' + assert responses.calls[0].request.body.decode( + 'utf-8') == '{"name": "test-credential", "type": "aws-ecr", "access_key_id": "test-key", "secret_access_key": "test-secret", "region": "us-west-2", "ecr_repo": "test.ecr.aws.com"}' @responses.activate def test_add_registry_credentials_custom(self, containers_service, registry_credentials_endpoint): @@ -822,7 +841,8 @@ def test_add_registry_credentials_custom(self, containers_service, registry_cred # assert assert responses.assert_call_count( registry_credentials_endpoint, 1) is True - assert responses.calls[0].request.body == '{"name": "test-credential", "registry_type": "custom", "docker_config_json": {"auths": {"registry.example.com": {"auth": "base64-encoded"}}}}' + assert responses.calls[0].request.body.decode( + 'utf-8') == '{"name": "test-credential", "type": "custom", "docker_config_json": "{\\"auths\\": {\\"registry.example.com\\": {\\"auth\\": \\"base64-encoded\\"}}}"}' @responses.activate def test_delete_registry_credentials(self, containers_service, registry_credentials_endpoint): From ce778189c61970694f5b9504b8a6ea8dea3b6828 Mon Sep 17 00:00:00 2001 From: Tamir Date: Fri, 21 Mar 2025 10:22:31 +0200 Subject: [PATCH 24/24] improve test coverage --- .../unit_tests/containers/test_containers.py | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/containers/test_containers.py b/tests/unit_tests/containers/test_containers.py index 493e984..6067808 100644 --- a/tests/unit_tests/containers/test_containers.py +++ b/tests/unit_tests/containers/test_containers.py @@ -27,6 +27,7 @@ QueueLoadScalingTrigger, UtilizationScalingTrigger, DockerHubCredentials, + GithubCredentials, GCRCredentials, AWSECRCredentials, CustomRegistryCredentials, @@ -614,16 +615,20 @@ def test_delete_deployment_environment_variables(self, containers_service, deplo responses.add( responses.DELETE, url, - json=[], # remaining env variables should be empty after deletion + json=ENV_VARS_DATA, status=200 ) # act result = containers_service.delete_deployment_environment_variables( - DEPLOYMENT_NAME, CONTAINER_NAME, [ENV_VAR_NAME]) + DEPLOYMENT_NAME, CONTAINER_NAME, ["random-env-var-name"]) # assert - assert len(result) == 0 + assert result == {CONTAINER_NAME: [EnvVar( + name=ENV_VAR_NAME, + value_or_reference_to_secret=ENV_VAR_VALUE, + type=EnvVarType.PLAIN + )]} assert responses.assert_call_count(url, 1) is True @responses.activate @@ -773,6 +778,29 @@ def test_add_registry_credentials(self, containers_service, registry_credentials assert responses.calls[0].request.body.decode( 'utf-8') == '{"name": "test-credential", "type": "dockerhub", "username": "username", "access_token": "token"}' + @responses.activate + def test_add_registry_credentials_github(self, containers_service, registry_credentials_endpoint): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + creds = GithubCredentials( + name=REGISTRY_CREDENTIAL_NAME, + username="test-username", + access_token="test-token" + ) + containers_service.add_registry_credentials(creds) + + # assert + assert responses.assert_call_count( + registry_credentials_endpoint, 1) is True + assert responses.calls[0].request.body.decode( + 'utf-8') == '{"name": "test-credential", "type": "ghcr", "username": "test-username", "access_token": "test-token"}' + @responses.activate def test_add_registry_credentials_gcr(self, containers_service, registry_credentials_endpoint): # arrange