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 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) ------------------- diff --git a/datacrunch/containers/__init__.py b/datacrunch/containers/__init__.py new file mode 100644 index 0000000..5036b1b --- /dev/null +++ b/datacrunch/containers/__init__.py @@ -0,0 +1,30 @@ +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, + BaseRegistryCredentials, + DockerHubCredentials, + GithubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials, +) diff --git a/datacrunch/containers/containers.py b/datacrunch/containers/containers.py new file mode 100644 index 0000000..9cb2283 --- /dev/null +++ b/datacrunch/containers/containers.py @@ -0,0 +1,673 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json, Undefined # type: ignore +from typing import List, Optional, Dict +from enum import Enum + + +# API endpoints +CONTAINER_DEPLOYMENTS_ENDPOINT = '/container-deployments' +SERVERLESS_COMPUTE_RESOURCES_ENDPOINT = '/serverless-compute-resources' +CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT = '/container-registry-credentials' +SECRETS_ENDPOINT = '/secrets' + + +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: + """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 + + +@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 + + +@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 + + +@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 + + +@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 + healthcheck: Optional[HealthcheckSettings] = None + entrypoint_overrides: Optional[EntrypointOverridesSettings] = None + env: Optional[List[EnvVar]] = None + volume_mounts: Optional[List[VolumeMount]] = None + + +@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 + + +@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 + is_available: Optional[bool] = None + + +@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 + + +@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 + + +@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 + 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: + """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] + compute: ComputeResource + is_spot: bool = False + endpoint_base_url: Optional[str] = None + scaling: Optional[ScalingOptions] = None + created_at: Optional[str] = None + + +@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: str + + +@dataclass_json +@dataclass +class Secret: + """A secret model class""" + name: str + created_at: str + + +@dataclass_json +@dataclass +class RegistryCredential: + """A container registry credential model class""" + name: str + 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""" + + 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_deployments(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_deployment_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_deployment( + 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_deployment(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_deployment(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_deployment_status(self, deployment_name: str) -> ContainerDeploymentStatus: + """Get deployment status + + :param deployment_name: name of the deployment + :type deployment_name: str + :return: deployment status + :rtype: ContainerDeploymentStatus + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/status") + return ContainerDeploymentStatus(response.json()["status"]) + + def restart_deployment(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_deployment_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: ScalingOptions + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling") + return ScalingOptions.from_dict(response.json()) + + def update_deployment_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: ScalingOptions + :return: updated scaling options + :rtype: ScalingOptions + """ + response = self.client.patch( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/scaling", + scaling_options.to_dict() + ) + return ScalingOptions.from_dict(response.json()) + + 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: list of replicas information + :rtype: List[ReplicaInfo] + """ + response = self.client.get( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/replicas") + return [ReplicaInfo.from_dict(replica) for replica in response.json()["list"]] + + def purge_deployment_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_deployment(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_deployment(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_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: 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") + 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 + :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[EnvVar] + :return: updated environment variables + :rtype: Dict[str, List[EnvVar]] + """ + response = self.client.post( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": [ + env_var.to_dict() for env_var in env_vars]} + ) + 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 + :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[EnvVar] + :return: updated environment variables + :rtype: Dict[str, List[EnvVar]] + """ + response = self.client.patch( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": [ + env_var.to_dict() for env_var in env_vars]} + ) + 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 + :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[str, List[EnvVar]] + """ + response = self.client.delete( + f"{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/environment-variables", + {"container_name": container_name, "env": env_var_names} + ) + 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 + + :return: list of compute resources + :rtype: List[ComputeResource] + """ + response = self.client.get(SERVERLESS_COMPUTE_RESOURCES_ENDPOINT) + 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 + + :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) -> None: + """Create a new secret + + :param name: name of the secret + :type name: str + :param value: value of the secret + :type value: str + """ + self.client.post(SECRETS_ENDPOINT, {"name": name, "value": value}) + + 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": str(force).lower()}) + + 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, credentials: BaseRegistryCredentials) -> None: + """Add registry credentials + + :param credentials: Registry credentials object + :type credentials: BaseRegistryCredentials + """ + data = credentials.to_dict() + self.client.post(CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, data) + + 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/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/compute_resources_example.py b/examples/containers/compute_resources_example.py new file mode 100644 index 0000000..501194d --- /dev/null +++ b/examples/containers/compute_resources_example.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() diff --git a/examples/containers/container_deployments_example.py b/examples/containers/container_deployments_example.py new file mode 100644 index 0000000..f0cd992 --- /dev/null +++ b/examples/containers/container_deployments_example.py @@ -0,0 +1,223 @@ +"""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 datacrunch import DataCrunchClient +from datacrunch.exceptions import APIException +from datacrunch.containers.containers import ( + Container, + ComputeResource, + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, + HealthcheckSettings, + VolumeMount, + ContainerRegistrySettings, + Deployment, + VolumeMountType, + ContainerDeploymentStatus, +) + +# 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_client = None + + +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_deployment_status(deployment_name) + print(f"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: DataCrunch API client + """ + try: + # Delete deployment + client.containers.delete_deployment(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: + # 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 + global datacrunch_client + datacrunch_client = DataCrunchClient( + DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) + + # Create container configuration + container = Container( + name=CONTAINER_NAME, + image=IMAGE_NAME, + exposed_port=80, + healthcheck=HealthcheckSettings( + enabled=True, + port=80, + path="/health" + ), + volume_mounts=[ + VolumeMount( + type=VolumeMountType.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_client.containers.create_deployment( + deployment) + print(f"Created deployment: {created_deployment.name}") + + # Wait for deployment to be healthy + if not wait_for_deployment_health(datacrunch_client, DEPLOYMENT_NAME): + print("Deployment health check failed") + cleanup_resources(datacrunch_client) + return + + # Update scaling configuration + try: + deployment = datacrunch_client.containers.get_deployment_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_client.containers.update_deployment( + 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_client.containers.pause_deployment(DEPLOYMENT_NAME) + print("Deployment paused") + time.sleep(60) + + # Resume deployment + datacrunch_client.containers.resume_deployment(DEPLOYMENT_NAME) + print("Deployment resumed") + + # Restart deployment + datacrunch_client.containers.restart_deployment(DEPLOYMENT_NAME) + print("Deployment restarted") + + # Purge queue + datacrunch_client.containers.purge_deployment_queue( + DEPLOYMENT_NAME) + print("Queue purged") + except APIException as e: + print(f"Error in deployment operations: {e}") + + # Clean up + 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_client) + except Exception as cleanup_error: + print(f"Error during cleanup after failure: {cleanup_error}") + + +if __name__ == "__main__": + main() 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() diff --git a/examples/containers/registry_credentials_example.py b/examples/containers/registry_credentials_example.py new file mode 100644 index 0000000..6c20f94 --- /dev/null +++ b/examples/containers/registry_credentials_example.py @@ -0,0 +1,92 @@ +import os +from datacrunch import DataCrunchClient +from datacrunch.containers import ( + DockerHubCredentials, + GithubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials +) + +# 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) + +# Example 1: DockerHub Credentials +dockerhub_creds = DockerHubCredentials( + name="my-dockerhub-creds", + 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 +github_creds = GithubCredentials( + name="my-github-creds", + 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 +# 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" +}""" + +gcr_creds = GCRCredentials( + name="my-gcr-creds", + service_account_key=gcr_service_account_key +) +datacrunch_client.containers.add_registry_credentials(gcr_creds) +print("Created GCR credentials") + +# Example 4: AWS ECR Credentials +aws_creds = AWSECRCredentials( + name="my-aws-ecr-creds", + 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 +custom_docker_config = """{ + "auths": { + "your-custom-registry.com": { + "auth": "base64-encoded-username-password" + } + } +}""" + +custom_creds = CustomRegistryCredentials( + name="my-custom-registry-creds", + docker_config_json=custom_docker_config +) +datacrunch_client.containers.add_registry_credentials(custom_creds) +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/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_example.py b/examples/containers/sglang_deployment_example.py new file mode 100644 index 0000000..43e7195 --- /dev/null +++ b/examples/containers/sglang_deployment_example.py @@ -0,0 +1,322 @@ +"""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_client = None + + +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: + 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 = datacrunch_client.containers.get_deployment_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(datacrunch_client: DataCrunchClient) -> None: + """Clean up all created resources. + + Args: + client: DataCrunchAPI client + """ + try: + # Delete deployment + datacrunch_client.containers.delete_deployment(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_client) + 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_client + datacrunch_client = 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_client.containers.get_secrets() + secret_exists = any( + secret.name == HF_SECRET_NAME for secret in existing_secrets) + + if not secret_exists: + datacrunch_client.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_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_client, DEPLOYMENT_NAME): + print("Deployment health check failed") + cleanup_resources(datacrunch_client) + 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_client) + 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_client) + except Exception as cleanup_error: + print(f"Error during cleanup after failure: {cleanup_error}") + + +if __name__ == "__main__": + main() diff --git a/examples/containers/update_deployment_scaling_example.py b/examples/containers/update_deployment_scaling_example.py new file mode 100644 index 0000000..e698b40 --- /dev/null +++ b/examples/containers/update_deployment_scaling_example.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_deployment_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_deployment_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 + datacrunch_client = DataCrunchClient( + DATACRUNCH_CLIENT_ID, DATACRUNCH_CLIENT_SECRET) + + # Verify deployment exists + 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(datacrunch_client, DEPLOYMENT_NAME) + + # Get current 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}") + 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..e96c9af --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +certifi==2025.1.31 +charset-normalizer==3.4.1 +dataclasses-json==0.6.7 +idna==3.10 +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 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..6067808 --- /dev/null +++ b/tests/unit_tests/containers/test_containers.py @@ -0,0 +1,890 @@ +import pytest +import responses # https://github.com/getsentry/responses +from responses import matchers + +from datacrunch.containers.containers import ( + CONTAINER_DEPLOYMENTS_ENDPOINT, + CONTAINER_REGISTRY_CREDENTIALS_ENDPOINT, + SECRETS_ENDPOINT, + SERVERLESS_COMPUTE_RESOURCES_ENDPOINT, + Container, + ContainerDeploymentStatus, + ContainerRegistrySettings, + ContainersService, + Deployment, + EnvVar, + EnvVarType, + EntrypointOverridesSettings, + HealthcheckSettings, + RegistryCredential, + Secret, + VolumeMount, + VolumeMountType, + ComputeResource, + ScalingOptions, + ScalingPolicy, + ScalingTriggers, + QueueLoadScalingTrigger, + UtilizationScalingTrigger, + DockerHubCredentials, + GithubCredentials, + GCRCredentials, + AWSECRCredentials, + CustomRegistryCredentials, + ReplicaInfo, +) +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:00+00:00" +} + +# 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:00+00:00" + } +] + +# Sample registry credentials data +REGISTRY_CREDENTIALS_DATA = [ + { + "name": REGISTRY_CREDENTIAL_NAME, + "created_at": "2023-01-01T00:00:00+00:00" + } +] + +# Sample deployment status data +DEPLOYMENT_STATUS_DATA = { + "status": "healthy" +} + +# Sample replicas data +REPLICAS_DATA = { + "list": [ + { + "id": "replica-1", + "status": "running", + "started_at": "2023-01-01T00:00:00+00:00" + } + ] +} + + +# Sample environment variables data +ENV_VARS_DATA = [{ + "container_name": CONTAINER_NAME, + "env": [ + { + "name": ENV_VAR_NAME, + "value_or_reference_to_secret": ENV_VAR_VALUE, + "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_deployments() + 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_deployment_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_deployment_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(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( + 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(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_deployment_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(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_deployment_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_deployment_scaling_options( + DEPLOYMENT_NAME) + + # assert + 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 + def test_update_deployment_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 + ) + + # 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 + updated_scaling = containers_service.update_deployment_scaling_options( + DEPLOYMENT_NAME, scaling_options) + + # assert + 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 + def test_get_deployment_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_deployment_replicas(DEPLOYMENT_NAME) + + # assert + 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 + def test_purge_deployment_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_deployment_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(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(DEPLOYMENT_NAME) + + # assert + assert responses.assert_call_count(url, 1) is True + + @responses.activate + 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( + responses.GET, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + env_vars = containers_service.get_deployment_environment_variables( + DEPLOYMENT_NAME) + + # assert + 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 + 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( + responses.POST, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + 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] == [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 + 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( + responses.PATCH, + url, + json=ENV_VARS_DATA[0], + status=200 + ) + + # act + 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] == [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 + 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( + responses.DELETE, + url, + json=ENV_VARS_DATA, + status=200 + ) + + # act + result = containers_service.delete_deployment_environment_variables( + DEPLOYMENT_NAME, CONTAINER_NAME, ["random-env-var-name"]) + + # assert + 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 + def test_get_compute_resources(self, containers_service, compute_resources_endpoint): + # arrange - add response mock + responses.add( + responses.GET, + compute_resources_endpoint, + # Wrap in list to simulate resource groups + 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, + 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 + containers_service.create_secret(SECRET_NAME, SECRET_VALUE) + + # assert + 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}?force=false" + responses.add( + responses.DELETE, + url, + status=200 + ) + + # act + containers_service.delete_secret(SECRET_NAME) + + # 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}?force=true" + 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): + # 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): + USERNAME = "username" + ACCESS_TOKEN = "token" + # arrange - add response mock + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + creds = DockerHubCredentials( + name=REGISTRY_CREDENTIAL_NAME, + 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.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 + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + service_account_key = '{"key": "value"}' + 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.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): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + 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.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): + # arrange + responses.add( + responses.POST, + registry_credentials_endpoint, + status=201 + ) + + # act + docker_config = '{"auths": {"registry.example.com": {"auth": "base64-encoded"}}}' + 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.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): + # 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