diff --git a/dbt_platform_helper/COMMANDS.md b/dbt_platform_helper/COMMANDS.md index af31f4f95..8e24d783a 100644 --- a/dbt_platform_helper/COMMANDS.md +++ b/dbt_platform_helper/COMMANDS.md @@ -9,6 +9,7 @@ - [platform-helper codebase list](#platform-helper-codebase-list) - [platform-helper codebase build](#platform-helper-codebase-build) - [platform-helper codebase deploy](#platform-helper-codebase-deploy) + - [platform-helper codebase redeploy](#platform-helper-codebase-redeploy) - [platform-helper conduit](#platform-helper-conduit) - [platform-helper config](#platform-helper-config) - [platform-helper config validate](#platform-helper-config-validate) @@ -159,7 +160,7 @@ platform-helper application task-stats --env --app [ ## Usage ``` -platform-helper codebase (prepare|list|build|deploy) +platform-helper codebase ``` ## Options @@ -173,6 +174,7 @@ platform-helper codebase (prepare|list|build|deploy) - [`deploy` ↪](#platform-helper-codebase-deploy) - [`list` ↪](#platform-helper-codebase-list) - [`prepare` ↪](#platform-helper-codebase-prepare) +- [`redeploy` ↪](#platform-helper-codebase-redeploy) # platform-helper codebase prepare @@ -265,6 +267,33 @@ platform-helper codebase deploy --app --env --codeba - `--help ` _Defaults to False._ - Show this message and exit. +# platform-helper codebase redeploy + +[↩ Parent](#platform-helper-codebase) + + Get the current deployed image, extract the deployed image and redeploy + it for a list of codebases or all in platform-config.yml. + +## Usage + +``` +platform-helper codebase redeploy --app [--env ] [--codebases ] + [--wait ] +``` + +## Options + +- `--app ` + - Application name +- `--env ` + - Environment to redeploy +- `--codebases ` _Defaults to []._ + - The codebase name as specified in the platform-config.yml file. This can be run from any directory. +- `--wait ` _Defaults to True._ + - Wait on pipelines completing before returning results +- `--help ` _Defaults to False._ + - Show this message and exit. + # platform-helper conduit [↩ Parent](#platform-helper) diff --git a/dbt_platform_helper/commands/codebase.py b/dbt_platform_helper/commands/codebase.py index 6edaaf206..3a6a23ff5 100644 --- a/dbt_platform_helper/commands/codebase.py +++ b/dbt_platform_helper/commands/codebase.py @@ -1,10 +1,19 @@ +from typing import List + import click from dbt_platform_helper.domain.codebase import Codebase +from dbt_platform_helper.domain.codebase import RedeployDisplay from dbt_platform_helper.domain.versioning import PlatformHelperVersioning from dbt_platform_helper.platform_exception import PlatformException +from dbt_platform_helper.providers.aws.codepipeline import CodePipeline +from dbt_platform_helper.providers.config import ConfigProvider +from dbt_platform_helper.providers.config_validator import ConfigValidator +from dbt_platform_helper.providers.ecs import ECS +from dbt_platform_helper.providers.files import LocalFileSystem from dbt_platform_helper.providers.io import ClickIOProvider from dbt_platform_helper.providers.parameter_store import ParameterStore +from dbt_platform_helper.utils.application import load_application from dbt_platform_helper.utils.aws import get_aws_session_or_abort from dbt_platform_helper.utils.click import ClickDocOptGroup @@ -91,3 +100,56 @@ def deploy( ) except PlatformException as err: ClickIOProvider().abort_with_error(str(err)) + + +@codebase.command() +@click.option("--app", help="Application name", required=True) +@click.option( + "--env", + help="Environment to redeploy", + type=str, +) +@click.option( + "--codebases", + type=str, + multiple=True, + required=False, + default=[], + help="The codebase name as specified in the platform-config.yml file. This can be run from any directory.", +) +@click.option( + "--wait", type=bool, default=True, help="Wait on pipelines completing before returning results" +) +def redeploy(app: str, env: str, codebases: List[str], wait: bool): + """Get the current deployed image, extract the deployed image and redeploy + it for a list of codebases or all in platform-config.yml.""" + try: + # currently logged in account (one with pipelines) + session = get_aws_session_or_abort() + + application = load_application(app) + if env not in application.environments: + raise PlatformException(f"Environment '{env}' not found in application {app}.") + # account where env exists + env_session = application.environments[env].session + param_store = ParameterStore(env_session.client("ssm")) + + config_provider = ConfigProvider(ConfigValidator(session=env_session)) + + results = Codebase( + param_store, + config=config_provider, + pipeline=CodePipeline(session), + deployment=ECS( + env_session.client("ecs"), env_session.client("ssm"), application_name=app, env=env + ), + file_system=LocalFileSystem(), + ).redeploy(app, env, codebases, wait=wait) + + display = RedeployDisplay() + + ClickIOProvider().info(display.format_results(results, wait)) + ClickIOProvider().info(display.format_summary(results, wait)) + + except PlatformException as err: + ClickIOProvider().abort_with_error(str(err)) diff --git a/dbt_platform_helper/domain/codebase.py b/dbt_platform_helper/domain/codebase.py index b6d090058..241bc62f5 100644 --- a/dbt_platform_helper/domain/codebase.py +++ b/dbt_platform_helper/domain/codebase.py @@ -1,14 +1,28 @@ import json import stat import subprocess +import time +from abc import ABC +from collections import defaultdict from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +from typing import List +from typing import Optional +from typing import Set import requests import yaml from boto3 import Session +from prettytable import PrettyTable from dbt_platform_helper.platform_exception import PlatformException +from dbt_platform_helper.ports.config import ConfigPort +from dbt_platform_helper.ports.deployed import DeploymentPort +from dbt_platform_helper.ports.deployed import PipelineDetails +from dbt_platform_helper.ports.deployed import PipelinePort +from dbt_platform_helper.ports.deployed import PipelineStatus +from dbt_platform_helper.ports.file_system import FileSystemPort from dbt_platform_helper.providers.ecr import ECRProvider from dbt_platform_helper.providers.files import FileProvider from dbt_platform_helper.providers.io import ClickIOProvider @@ -29,6 +43,25 @@ from dbt_platform_helper.utils.template import setup_templates +@dataclass +class Deployment(ABC): + codebase: str + pipeline: str + tag: str + execution_id: str + + +@dataclass +class RedployResult(ABC): + codebase: str + pipeline: str + execution_id: Optional[str] + status: str + tag: Optional[str] + error: Optional[str] = None + url: Optional[str] = None + + class Codebase: def __init__( self, @@ -49,6 +82,10 @@ def __init__( [str], str ] = start_pipeline_and_return_execution_id, run_subprocess: Callable[[str], str] = subprocess.run, + config: ConfigPort = None, + deployment: DeploymentPort = None, + pipeline: PipelinePort = None, + file_system: FileSystemPort = None, ): self.parameter_provider = parameter_provider self.io = io @@ -63,6 +100,10 @@ def __init__( self.start_build_extraction = start_build_extraction self.start_pipeline_and_return_execution_id = start_pipeline_and_return_execution_id self.run_subprocess = run_subprocess + self.config = config + self.deployment = deployment + self.pipeline = pipeline + self.file_system = file_system def prepare(self): """Sets up an application codebase for use within a DBT platform @@ -292,6 +333,181 @@ def _validate_sha_length(self, commit): "Your commit reference is too short. Commit sha hashes specified by '--commit' must be at least 7 characters long." ) + def redeploy( + self, + app: str, + env: str, + codebases: List[str], + wait: bool = True, + poll_interval: int = 60, + wait_timeout: int = 1800, + ) -> List[RedployResult]: + + service_to_codebase = {} + codebase_tags = defaultdict(set) + mismatched_commits = [] + deployments: List[Deployment] = [] + results = [] + + cwd = self.file_system.get_current_directory() + if not codebases and "-deploy" not in cwd.parts[-1]: + raise PlatformException("Not in deploy repo") + + config = self.config.load_and_validate_platform_config() + codebase_pipelines = config.get("codebase_pipelines", {}) + if not codebases: + codebases = codebase_pipelines.keys() + + for codebase in codebases: + codebase_config = codebase_pipelines.get(codebase, {}) + for run_group in codebase_config.get("services", []): + for _, services in run_group.items(): + for service in services: + service_to_codebase[service] = codebase + + env_object = config.get("environments", {}).get(env, {}) + default_env_object = config.get("environments", {}).get("*", {}) + deployment_mode = ( + env_object.get("service-deployment-mode", "copilot") + if env_object + else default_env_object.get("service-deployment-mode", "copilot") + ) + platformed = deployment_mode in ["dual-traffic-platform-mode", "platform"] + services = self.deployment.get_deployed_services(app, env, platformed) + for service in services: + codebase = service_to_codebase.get(service.name, "") + if codebase: + codebase_tags[codebase].add(service.tag) + + for codebase, deployed_commits in codebase_tags.items(): + if len(deployed_commits) > 1: + mismatched_commits.append((codebase, deployed_commits)) + + if mismatched_commits: + message = "Commit mismatch on deployed services for the following codebases:\n" + for codebase in mismatched_commits: + message += f"- {codebase[0]}\n" + raise PlatformException(message) + + for codebase, deployed_tags in codebase_tags.items(): + tag = deployed_tags.pop() + + pipeline_name = f"{app}-{codebase}-manual-release" + # TODO can be removed when no one has copilot pipelines + if not self.pipeline.pipeline_exists(pipeline_name): + pipeline_name += "-pipeline" + + confirmation_message = f'\nFor the application "{app}", you are about to redeploy the codebase "{codebase}" with image reference "{tag}" (corresponding to the "{env}" environment using the "{pipeline_name}" deployment pipeline. Do you want to continue?' + + if self.io.confirm(confirmation_message): + execution_id = self.pipeline.trigger_deployment( + PipelineDetails( + name=pipeline_name, + image_tag=tag, + environment=env, + ) + ) + + if execution_id: + deployments.append( + Deployment( + codebase=codebase, + pipeline=pipeline_name, + execution_id=execution_id, + tag=tag, + ) + ) + else: + results.append( + RedployResult( + codebase=codebase, + pipeline=pipeline_name, + execution_id=None, + status="not triggered", + tag=tag, + error="Pipeline trigger failed", + ) + ) + + if deployments and wait: + completed_results = self._wait_for_all_pipelines( + deployments, poll_interval, wait_timeout + ) + results.extend(completed_results) + else: + for deployment in deployments: + results.append( + RedployResult( + codebase=deployment.codebase, + pipeline=deployment.pipeline, + execution_id=deployment.execution_id, + status="triggered", + tag=deployment.tag, + url=self.pipeline.get_pipeline_url( + deployment.pipeline, deployment.execution_id + ), + ) + ) + return results + + def _wait_for_all_pipelines( + self, deployments: List[Deployment], poll_interval: int, wait_timeout: int + ) -> List[RedployResult]: + start_time = time.time() + pending: Set[str] = {deployment.pipeline for deployment in deployments} + pipeline_map = {deployment.pipeline: deployment for deployment in deployments} + results = [] + + while pending: + elapsed = time.time() - start_time + if elapsed > wait_timeout: + for pipeline in list(pending): + deployment = pipeline_map[pipeline] + execution = self.pipeline.get_execution_status( + pipeline, execution_id=deployment.execution_id + ) + if execution: + status = execution.status.value.lower() + error = "Timeout" if not execution.is_complete else None + else: + status = "Unknown" + error = "Failed to get status" + + results.append( + RedployResult( + codebase=deployment.codebase, + pipeline=deployment.pipeline, + execution_id=deployment.execution_id, + status=status, + tag=deployment.tag, + error=error, + ) + ) + break + for pipeline in list(pending): + deployment = pipeline_map[pipeline] + execution = self.pipeline.get_execution_status( + pipeline, execution_id=deployment.execution_id + ) + if execution and execution.is_complete: + results.append( + RedployResult( + codebase=deployment.codebase, + pipeline=deployment.pipeline, + execution_id=deployment.execution_id, + status=execution.status.value.lower(), + tag=deployment.tag, + error=None if execution.is_successful else "Pipeline failed", + ) + ) + pending.remove(pipeline) + + if pending: + self.io.info(f"Executions for {pending} still pending ...") + time.sleep(poll_interval) + + return results + class ApplicationDeploymentNotTriggered(PlatformException): def __init__(self, codebase: str): @@ -303,3 +519,90 @@ def __init__(self): super().__init__( "You are in the deploy repository; make sure you are in the application codebase repository.", ) + + +class RedeployDisplay: + + def format_results(self, results: List[RedployResult], waiting: bool) -> str: + if waiting: + return self._format_with_status(results) + else: + return self._format_with_url(results) + + def _format_with_status(self, results: List[RedployResult]): + table = PrettyTable() + field_names = ["Codebase", "Tag", "Status", "Exec ID", "Error"] + table.field_names = field_names + + for name in field_names: + table.align[name] = "l" + + for result in results: + table.add_row( + [ + result.codebase, + result.tag, + result.status, + self._format_execution_id(result.execution_id), + self._format_error(result.error), + ] + ) + return str(table) + + def _format_with_url(self, results: List[RedployResult]): + table = PrettyTable() + field_names = ["Codebase", "Tag", "Exec ID", "Url"] + table.field_names = field_names + + for name in field_names: + table.align[name] = "l" + table.max_width["Url"] = 125 + for result in results: + table.add_row( + [ + result.codebase, + result.tag, + self._format_execution_id(result.execution_id), + result.url, + ] + ) + + return str(table) + + def format_summary(self, results: List[RedployResult], waiting: bool) -> str: + if waiting: + succeeded = sum( + 1 for result in results if result.status == PipelineStatus.SUCCEEDED.value.lower() + ) + failed = sum( + 1 + for result in results + if result.status in [PipelineStatus.FAILED.value.lower(), "not triggered"] + ) + in_progress = sum( + 1 for result in results if result.status == PipelineStatus.IN_PROGRESS.value.lower() + ) + return ( + "\nSummary: " + f"{succeeded} succeeded, " + f"{failed} failed, " + f"{in_progress} in_progress, " + ) + else: + triggered = sum(1 for result in results if result.execution_id) + not_triggered = len(results) - triggered + + return "\nSummary: " f"{triggered} triggered, " f"{not_triggered} failed to trigger" + + def _format_execution_id(self, execution_id: str) -> str: + return execution_id[:8] + "..." if len(execution_id) > 8 else execution_id + + def _format_error(self, error: Optional[str]) -> str: + + if not error: + return "-" + + if len(error) > 40: + return error[: 40 - 3] + "..." + + return error diff --git a/dbt_platform_helper/ports/config.py b/dbt_platform_helper/ports/config.py new file mode 100644 index 000000000..3bb5f3c4b --- /dev/null +++ b/dbt_platform_helper/ports/config.py @@ -0,0 +1,31 @@ +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Dict + + +class ConfigPort(ABC): + + @abstractmethod + def load_unvalidated_config_file(self, path: str) -> Dict[str, Any]: + pass + + @abstractmethod + def get_enriched_config(self) -> Dict[str, Any]: + pass + + @abstractmethod + def load_and_validate_platform_config(self, path: str) -> Dict[str, Any]: + pass + + @abstractmethod + def config_file_check(self, path: str): + pass + + @staticmethod + def apply_environment_defaults(config: Dict) -> Dict[str, Any]: + pass + + @abstractmethod + def write_platform_config(self, new_platform_config: Dict): + pass diff --git a/dbt_platform_helper/ports/deployed.py b/dbt_platform_helper/ports/deployed.py new file mode 100644 index 000000000..dd146194a --- /dev/null +++ b/dbt_platform_helper/ports/deployed.py @@ -0,0 +1,76 @@ +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +@dataclass +class DeployedService: + name: str + tag: str + environment: str + + +class PipelineStatus(Enum): + IN_PROGRESS = "InProgress" + SUCCEEDED = "Succeeded" + FAILED = "Failed" + STOPPED = "Stopped" + STOPPING = "Stopping" + SUPERSEDED = "Superseded" + + +@dataclass +class PipelineExecution: + execution_id: str + status: PipelineStatus + name: str + + @property + def is_complete(self) -> bool: + return self.status in [ + PipelineStatus.SUCCEEDED, + PipelineStatus.FAILED, + PipelineStatus.STOPPED, + PipelineStatus.SUPERSEDED, + ] + + def is_successful(self) -> bool: + return self.status == PipelineStatus.SUCCEEDED + + +@dataclass +class PipelineDetails: + name: str + image_tag: str + environment: Optional[str] = None + + +class DeploymentPort(ABC): + + @abstractmethod + def get_deployed_services( + self, application: str, environment: str, platform: bool = True + ) -> list[DeployedService]: + pass + + +class PipelinePort(ABC): + @abstractmethod + def trigger_deployment(self, details: PipelineDetails) -> Optional[str]: + pass + + @abstractmethod + def get_execution_status( + self, pipeline_name: str, execution_id: str + ) -> Optional[PipelineExecution]: + pass + + @abstractmethod + def pipeline_exists(self, pipeline_name: str) -> bool: + pass + + @abstractmethod + def get_pipeline_url(self, pipeline_name: str, execution_id: str) -> str: + pass diff --git a/dbt_platform_helper/ports/file_system.py b/dbt_platform_helper/ports/file_system.py new file mode 100644 index 000000000..bc4650ef7 --- /dev/null +++ b/dbt_platform_helper/ports/file_system.py @@ -0,0 +1,10 @@ +from abc import ABC +from abc import abstractmethod +from pathlib import Path + + +class FileSystemPort(ABC): + + @abstractmethod + def get_current_directory(self) -> Path: + pass diff --git a/dbt_platform_helper/providers/aws/codepipeline.py b/dbt_platform_helper/providers/aws/codepipeline.py new file mode 100644 index 000000000..30655154b --- /dev/null +++ b/dbt_platform_helper/providers/aws/codepipeline.py @@ -0,0 +1,55 @@ +import boto3 + +from dbt_platform_helper.ports.deployed import PipelineDetails +from dbt_platform_helper.ports.deployed import PipelineExecution +from dbt_platform_helper.ports.deployed import PipelinePort +from dbt_platform_helper.ports.deployed import PipelineStatus +from dbt_platform_helper.providers.io import ClickIOProvider + + +class CodePipeline(PipelinePort): + + def __init__(self, session: boto3.session.Session, io: ClickIOProvider = ClickIOProvider()): + self.codepipeline_client = session.client("codepipeline") + self.io = io + + def trigger_deployment(self, details: PipelineDetails): + variables = [ + {"name": "IMAGE_TAG", "value": details.image_tag}, + ] + if details.environment: + variables.append({"name": "ENVIRONMENT", "value": details.environment}) + build_options = {"name": details.name, "variables": variables} + try: + execution_id = self.codepipeline_client.start_pipeline_execution(**build_options)[ + "pipelineExecutionId" + ] + except Exception as e: + self.io.error("Pipeline trigger failed with: " + str(e)) + execution_id = None + return execution_id + + def get_execution_status(self, pipeline_name: str, execution_id: str): + try: + response = self.codepipeline_client.get_pipeline_execution( + pipelineName=pipeline_name, pipelineExecutionId=execution_id + ) + status = response["pipelineExecution"]["status"] + + return PipelineExecution( + execution_id=execution_id, status=PipelineStatus(status), name=pipeline_name + ) + except Exception as e: + self.io.warn(f"Failed to get status for {pipeline_name}: {e}") + return None + + def pipeline_exists(self, pipeline_name): + try: + self.codepipeline_client.get_pipeline(name=pipeline_name) + return True + except Exception: + self.io.debug(f"Pipeline {pipeline_name} not found") + return False + + def get_pipeline_url(self, pipeline_name, execution_id): + return f"https://eu-west-2.console.aws.amazon.com/codesuite/codepipeline/pipelines/{pipeline_name}/executions/{execution_id}" diff --git a/dbt_platform_helper/providers/ecs.py b/dbt_platform_helper/providers/ecs.py index 4fc8bf4fd..fcba30d77 100644 --- a/dbt_platform_helper/providers/ecs.py +++ b/dbt_platform_helper/providers/ecs.py @@ -8,6 +8,8 @@ from dbt_platform_helper.platform_exception import PlatformException from dbt_platform_helper.platform_exception import ValidationException +from dbt_platform_helper.ports.deployed import DeployedService +from dbt_platform_helper.ports.deployed import DeploymentPort from dbt_platform_helper.providers.vpc import Vpc from dbt_platform_helper.utilities.decorators import retry from dbt_platform_helper.utilities.decorators import wait_until @@ -34,7 +36,7 @@ def __init__(self, command: str, err: str): super().__init__(f"Command `{command}` failed with error: {err}") -class ECS: +class ECS(DeploymentPort): def __init__(self, ecs_client, ssm_client, application_name: str, env: str): self.ecs_client = ecs_client self.ssm_client = ssm_client @@ -331,3 +333,58 @@ def execute(self, cluster, task, container, command): subprocess.run(aws_cli_cmd, check=True) except Exception as e: raise ECSExecException(" ".join(aws_cli_cmd), str(e)) + + def get_deployed_services(self, application: str, environment: str, platform: bool = True): + cluster_name = f"{application}-{environment}" + if platform: + cluster_name += "-cluster" + + services = [] + + service_arns = self._list_services(cluster_name) + + for i in range(0, len(service_arns), 10): + service_batch = service_arns[i : i + 10] + + response = self.ecs_client.describe_services( + cluster=cluster_name, services=service_batch + ) + + for service in response["services"]: + ecs_service_name = service["serviceName"] + task_def_arn = service["taskDefinition"] + service_name, tag = self._get_deployed_tag(ecs_service_name, task_def_arn) + + if tag: + services.append( + DeployedService(name=service_name, tag=tag, environment=environment) + ) + return services + + def _list_services(self, cluster: str) -> list[str]: + arns = [] + paginator = self.ecs_client.get_paginator("list_services") + for page in paginator.paginate(cluster=cluster): + arns.extend(page.get("serviceArns", [])) + return arns + + def _get_deployed_tag(self, ecs_service_name: str, task_def_arn: str) -> Optional[str]: + + response = self.ecs_client.describe_task_definition(taskDefinition=task_def_arn) + + task_def = response["taskDefinition"] + + service_name = None + container_def = {} + for container in task_def["containerDefinitions"]: + if container["name"] in ecs_service_name: + container_def = container + service_name = container["name"] + + if container_def: + image = container_def["image"] + if ":" in image: + tag = image.split(":")[-1] + return service_name, tag + + return service_name, None diff --git a/dbt_platform_helper/providers/files.py b/dbt_platform_helper/providers/files.py index 3a89f39e8..43e1596f8 100644 --- a/dbt_platform_helper/providers/files.py +++ b/dbt_platform_helper/providers/files.py @@ -1,5 +1,7 @@ from pathlib import Path +from dbt_platform_helper.ports.file_system import FileSystemPort + class FileProvider: @@ -25,3 +27,9 @@ def delete_file(base_path: str, file_name: str): if file_path.exists(): file_path.unlink() return f"{str(file_path)} has been deleted" + + +class LocalFileSystem(FileSystemPort): + + def get_current_directory(self): + return Path.cwd() diff --git a/tests/platform_helper/conftest.py b/tests/platform_helper/conftest.py index e7f1226a4..86c83760b 100644 --- a/tests/platform_helper/conftest.py +++ b/tests/platform_helper/conftest.py @@ -626,7 +626,7 @@ def valid_platform_config(): deploy_repository_branch: feature-branch additional_ecr_repository: public.ecr.aws/my-public-repo/test-app/application services: - - run_order_1: + - run_group_1: - celery-worker - celery-beat - web diff --git a/tests/platform_helper/domain/test_codebase.py b/tests/platform_helper/domain/test_codebase.py index 479e6c4e5..1e1576921 100644 --- a/tests/platform_helper/domain/test_codebase.py +++ b/tests/platform_helper/domain/test_codebase.py @@ -1,6 +1,7 @@ import filecmp import json import os +from dataclasses import dataclass from datetime import datetime from pathlib import Path from unittest.mock import MagicMock @@ -17,6 +18,7 @@ from dbt_platform_helper.domain.codebase import ApplicationEnvironmentNotFoundException from dbt_platform_helper.domain.codebase import Codebase from dbt_platform_helper.domain.codebase import NotInCodeBaseRepositoryException +from dbt_platform_helper.platform_exception import PlatformException from dbt_platform_helper.providers.aws.exceptions import AWSException from dbt_platform_helper.providers.aws.exceptions import RepositoryNotFoundException from dbt_platform_helper.utils.application import ApplicationNotFoundException @@ -682,3 +684,351 @@ def mock_run_suprocess_fixture(): mock_stdout = MagicMock() mock_stdout.configure_mock(**{"stdout.decode.return_value": '{"A": 3}'}) return mock_stdout + + +@dataclass +class MockService: + name: str + tag: str + + +@dataclass +class MockExecution: + status: Mock + is_complete: bool + is_successful: bool + + +@dataclass +class MockPipelineDetails: + name: str + image_tag: str + environment: str + + +class TestCodebaseRedploy: + + @pytest.fixture + def mock_ports(self): + return { + "parameter_provider": Mock(), + "io": Mock(), + "config": Mock(), + "deployment": Mock(), + "pipeline": Mock(), + "file_system": Mock(), + } + + @pytest.fixture + def standard_mock_ports(self, mock_ports, valid_platform_config): + mock_ports["file_system"].get_current_directory.return_value = Path( + "/repo/test-application-deploy" + ) + mock_ports["config"].load_and_validate_platform_config.return_value = valid_platform_config + return mock_ports + + @pytest.fixture + def standard_full_mock_ports(self, standard_mock_ports): + standard_mock_ports["deployment"].get_deployed_services.return_value = [ + MockService(name="web", tag="commit-123"), + MockService(name="celery-beat", tag="commit-123"), + MockService(name="celery-worker", tag="commit-123"), + ] + standard_mock_ports["pipeline"].pipeline_exists.return_value = True + standard_mock_ports["io"].confirm.return_value = True + standard_mock_ports["pipeline"].trigger_deployment.return_value = "exec-123" + + return standard_mock_ports + + @pytest.fixture + def codebase(self, mock_ports): + return Codebase(**mock_ports) + + @pytest.fixture + def base_config(self): + return { + "codebase_pipeline": { + "application": { + "services": [ + {"run_group_1": ["web"]}, + {"run_group_2": ["celery-beat", "celery-worker"]}, + ] + } + } + } + + @pytest.fixture + def mock_time_generator(self): + def _create_time_mock(time_values): + """ + To avoid out of bounds errors on time functions when mocked. + + It will get the last value after exceeding the list. + """ + time_call_count = [0] # list otherwise we get assignment issues + + def time_side_effect(): + if time_call_count[0] < len(time_values): + result = time_values[time_call_count[0]] + time_call_count[0] += 1 + return result + return time_values[-1] + + return time_side_effect + + return _create_time_mock + + def test_no_codebases_not_in_deploy(self, codebase: Codebase, mock_ports): + + mock_ports["file_system"].get_current_directory.return_value = Path("/some/other/repo") + + with pytest.raises(PlatformException, match="Not in deploy repo"): + codebase.redeploy(app="test-application", env="development", codebases=[]) + + def test_in_deploy_no_deployed_services(self, codebase: Codebase, standard_mock_ports): + standard_mock_ports["deployment"].get_deployed_services.return_value = [] + + results = codebase.redeploy(app="test-application", env="development", codebases=[]) + + assert results == [] + + def test_codebase_arg_no_deployed_services(self, codebase: Codebase, standard_mock_ports): + standard_mock_ports["deployment"].get_deployed_services.return_value = [] + + results = codebase.redeploy(app="test-application", env="development", codebases=[]) + + assert results == [] + + def test_no_codebase_mapped_service(self, codebase: Codebase, standard_mock_ports): + standard_mock_ports["deployment"].get_deployed_services.return_value = [ + MockService(name="unknown-service", tag="commit-123") + ] + + results = codebase.redeploy(app="test-application", env="development", codebases=[]) + + assert results == [] + + def test_tag_mismatch(self, codebase: Codebase, standard_mock_ports): + standard_mock_ports["deployment"].get_deployed_services.return_value = [ + MockService(name="web", tag="commit-12"), + MockService(name="celery-beat", tag="commit-45"), + MockService(name="celery-worker", tag="commit-67"), + ] + + with pytest.raises(PlatformException, match="Commit mismatch on deployed services"): + codebase.redeploy(app="test-application", env="development", codebases=["application"]) + + def test_user_declines_pipeline_trigger(self, codebase: Codebase, standard_full_mock_ports): + standard_full_mock_ports["io"].confirm.return_value = False + + results = codebase.redeploy( + app="test-application", env="development", codebases=["application"] + ) + + assert results == [] + standard_full_mock_ports["io"].confirm.assert_called_once() + standard_full_mock_ports["pipeline"].trigger_deployment.assert_not_called() + + def test_trigger_deployment_exception(self, codebase: Codebase, standard_full_mock_ports): + standard_full_mock_ports["pipeline"].trigger_deployment.return_value = None + + results = codebase.redeploy( + app="test-application", env="development", codebases=["application"] + ) + + assert len(results) == 1 + assert results[0].status == "not triggered" + assert results[0].error == "Pipeline trigger failed" + assert results[0].execution_id == None + assert results[0].tag == "commit-123" + + @patch("time.time") + @patch("time.sleep") + def test_wait_timeout( + self, + mock_sleep, + mock_time, + mock_time_generator, + codebase: Codebase, + standard_full_mock_ports, + ): + mock_time.side_effect = mock_time_generator([0, 6]) + standard_full_mock_ports["pipeline"].get_execution_status.return_value = None + + results = codebase.redeploy( + app="test-application", + env="development", + codebases=["application"], + poll_interval=1, + wait_timeout=5, + ) + + assert len(results) == 1 + assert results[0].status == "Unknown" + assert results[0].error == "Failed to get status" + assert results[0].execution_id == "exec-123" + + @patch("time.time") + @patch("time.sleep") + def test_wait_timeout_incomplete( + self, + mock_sleep, + mock_time, + mock_time_generator, + codebase: Codebase, + standard_full_mock_ports, + ): + mock_time.side_effect = mock_time_generator([0, 6]) + mock_status = Mock() + mock_status.value = "InProgress" + execution_incomplete = MockExecution( + status=mock_status, is_complete=False, is_successful=False + ) + + mock_status_finished = Mock() + mock_status_finished.value = "Succeeded" + execution = MockExecution( + status=mock_status_finished, is_complete=False, is_successful=False + ) + + standard_full_mock_ports["pipeline"].get_execution_status.side_effect = [ + execution_incomplete, + execution, + ] + + results = codebase.redeploy( + app="test-application", + env="development", + codebases=["application"], + poll_interval=1, + wait_timeout=5, + ) + + assert len(results) == 1 + assert results[0].status == "inprogress" + assert results[0].error == "Timeout" + assert results[0].execution_id == "exec-123" + + @patch("time.time") + @patch("time.sleep") + def test_wait_timeout_complete_timeout_first( + self, + mock_sleep, + mock_time, + mock_time_generator, + codebase: Codebase, + standard_full_mock_ports, + ): + mock_time.side_effect = mock_time_generator([0, 6]) + mock_status = Mock() + mock_status.value = "Succeeded" + execution = MockExecution(status=mock_status, is_complete=True, is_successful=True) + standard_full_mock_ports["pipeline"].get_execution_status.return_value = execution + + results = codebase.redeploy( + app="test-application", + env="development", + codebases=["application"], + poll_interval=1, + wait_timeout=5, + ) + + assert len(results) == 1 + assert results[0].status == "succeeded" + assert results[0].error is None + assert results[0].execution_id == "exec-123" + + @patch("time.time") + @patch("time.sleep") + def test_execution_successful( + self, + mock_sleep, + mock_time, + mock_time_generator, + codebase: Codebase, + standard_full_mock_ports, + ): + mock_time.side_effect = mock_time_generator([0, 30, 60]) + mock_status = Mock() + mock_status.value = "Succeeded" + execution = MockExecution(status=mock_status, is_complete=True, is_successful=True) + standard_full_mock_ports["pipeline"].get_execution_status.return_value = execution + + results = codebase.redeploy( + app="test-application", env="development", codebases=["application"] + ) + + assert len(results) == 1 + assert results[0].codebase == "application" + assert results[0].pipeline == "test-application-application-manual-release" + assert results[0].tag == "commit-123" + assert results[0].status == "succeeded" + assert results[0].error is None + assert results[0].execution_id == "exec-123" + + @patch("time.time") + @patch("time.sleep") + def test_execution_failed( + self, mock_sleep, mock_time, codebase: Codebase, standard_full_mock_ports + ): + mock_time.side_effect = [0, 30, 60] + mock_status = Mock() + mock_status.value = "Failed" + execution = MockExecution(status=mock_status, is_complete=True, is_successful=False) + standard_full_mock_ports["pipeline"].get_execution_status.return_value = execution + + results = codebase.redeploy( + app="test-application", env="development", codebases=["application"] + ) + + assert len(results) == 1 + assert results[0].codebase == "application" + assert results[0].pipeline == "test-application-application-manual-release" + assert results[0].tag == "commit-123" + assert results[0].status == "failed" + assert results[0].error == "Pipeline failed" + assert results[0].execution_id == "exec-123" + + def test_no_wait(self, codebase: Codebase, standard_full_mock_ports): + standard_full_mock_ports["pipeline"].get_pipeline_url.return_value = ( + "a-really-real-url-to-visit" + ) + + results = codebase.redeploy( + app="test-application", env="development", codebases=["application"], wait=False + ) + + assert len(results) == 1 + assert results[0].status == "triggered" + assert results[0].url == "a-really-real-url-to-visit" + assert results[0].execution_id == "exec-123" + standard_full_mock_ports["pipeline"].get_execution_status.assert_not_called() + + def test_multiple_codebases_not_all_confirmed( + self, codebase, standard_full_mock_ports, valid_platform_config + ): + valid_platform_config["codebase_pipelines"] = { + "first-app": {"services": [{"run_group_1": ["celery-worker", "celery-beat", "web"]}]}, + "second-app": { + "services": [{"run_group_1": ["celery-lifter", "celery-cheater", "heater"]}] + }, + } + + standard_full_mock_ports["config"].load_and_validate_platform_config.return_value = ( + valid_platform_config + ) + standard_full_mock_ports["pipeline"].get_pipeline_url.return_value = ( + "a-really-real-url-to-visit" + ) + standard_full_mock_ports["io"].confirm.side_effect = [True, False] + + results = codebase.redeploy( + app="test-application", + env="development", + codebases=["first-app", "second-app"], + wait=False, + ) + + assert len(results) == 1 + assert results[0].codebase == "first-app" + assert standard_full_mock_ports["pipeline"].trigger_deployment.call_count == 1 diff --git a/tests/platform_helper/integration/test_codebase.py b/tests/platform_helper/integration/test_codebase.py new file mode 100644 index 000000000..a804d1af3 --- /dev/null +++ b/tests/platform_helper/integration/test_codebase.py @@ -0,0 +1,184 @@ +from unittest.mock import MagicMock +from unittest.mock import Mock +from unittest.mock import create_autospec + +import pytest + +from dbt_platform_helper.domain.codebase import Codebase +from dbt_platform_helper.entities.semantic_version import SemanticVersion +from dbt_platform_helper.providers.aws.codepipeline import CodePipeline +from dbt_platform_helper.providers.config import ConfigProvider +from dbt_platform_helper.providers.config_validator import ConfigValidator +from dbt_platform_helper.providers.ecs import ECS +from dbt_platform_helper.providers.files import LocalFileSystem +from dbt_platform_helper.providers.version import InstalledVersionProvider + + +def mock_start_pipeline_execution(**kwargs): + # TODO add a call tracking count and gives different responses + if kwargs == { + "name": "test-application-application-manual-release", + "variables": [ + {"name": "IMAGE_TAG", "value": "commit-id"}, + {"name": "ENVIRONMENT", "value": "development"}, + ], + }: + return {"pipelineExecutionId": "doesnt-matter-id"} + else: + raise Exception("end") + + +def mock_get_pipeline_execution(**kwargs): + if kwargs == { + "pipelineName": "test-application-application-manual-release", + "pipelineExecutionId": "doesnt-matter-id", + }: + return { + "pipelineExecution": { + "pipelineName": "test-application-application-manual-release", + "status": "Succeeded", + } + } + else: + raise Exception("end") + + +def mock_describe_task_definition(**kwargs): + task_def = kwargs.get("taskDefinition") + if task_def == "web-task-def-arn": + return { + "taskDefinition": { + "family": "web-task-def", + "taskDefinitionArn": "web-task-def-arn", + "containerDefinitions": [ + {"name": "ipfilter"}, + {"name": "appconfig"}, + {"name": "web", "image": "image-doesnt-matter:commit-id"}, + ], + } + } + elif task_def == "celery-beat-task-def-arn": + return { + "taskDefinition": { + "family": "celery-beat-task-def", + "taskDefinitionArn": "celery-beat-task-def-arn", + "containerDefinitions": [ + {"name": "ipfilter"}, + {"name": "appconfig"}, + {"name": "celery-beat", "image": "image-doesnt-matter:commit-id"}, + ], + } + } + elif task_def == "celery-worker-task-def-arn": + return { + "taskDefinition": { + "family": "celery-worker-task-def", + "taskDefinitionArn": "celery-worker-task-def-arn", + "containerDefinitions": [ + {"name": "ipfilter"}, + {"name": "appconfig"}, + {"name": "celery-worker", "image": "image-doesnt-matter:commit-id"}, + ], + } + } + else: + raise Exception(f"Task definition not found: {task_def}") + + +@pytest.mark.parametrize( + "input_args", + [ + { + "app": "test-application", + "env": "development", + "codebases": ["application"], + }, + { + "app": "test-application", + "env": "development", + "codebases": ["application"], + "wait": False, + }, + # ({"app": "test-application", "env": "development", "codebases": []}, "none"), + ], +) +def test_redeploy(mock_application, fakefs, create_valid_platform_config_file, input_args): + + mock_codepipeline = Mock() + mock_codepipeline.start_pipeline_execution.side_effect = mock_start_pipeline_execution + mock_codepipeline.get_pipeline.return_value = True + mock_codepipeline.get_pipeline_execution.side_effect = mock_get_pipeline_execution + mock_session = Mock() + mock_session.client.return_value = mock_codepipeline + mock_ecs = Mock() + mock_ecs.describe_services.return_value = { + "services": [ + { + "serviceArn": "arn-doesnt-matter/celery-beat", + "serviceName": "test-application-development-web", + "clusterArn": "arn-doesnt-matter", + "taskDefinition": "web-task-def-arn", + }, + { + "serviceArn": "arn-doesnt-matter/celery-beat", + "serviceName": "test-application-development-celery-beat-ran", + "clusterArn": "arn-doesnt-matter", + "taskDefinition": "celery-beat-task-def-arn", + }, + { + "serviceArn": "arn-doesnt-matter/celery-worker", + "serviceName": "test-application-development-celery-worker", + "clusterArn": "arn-doesnt-matter", + "taskDefinition": "celery-worker-task-def-arn", + }, + ] + } + mock_ecs.get_paginator.return_value.paginate.return_value = [ + { + "serviceArns": [ + "arn-doesnt-matter/web", + "arn-doesnt-matter/celery-beat", + "arn-doesnt-matter/celery-worker", + ], + } + ] + mock_ecs.describe_task_definition.side_effect = mock_describe_task_definition + mock_ssm = Mock() + + io = MagicMock() + + mock_installed_version_provider = create_autospec(spec=InstalledVersionProvider, spec_set=True) + mock_installed_version_provider.get_semantic_version.return_value = SemanticVersion(14, 0, 0) + mock_config_validator = Mock(spec=ConfigValidator) + cb = Codebase( + parameter_provider=Mock(), # not used in redeploy + load_application=Mock(), # not used in redeploy + io=io, + config=ConfigProvider( + mock_config_validator, installed_version_provider=mock_installed_version_provider + ), + pipeline=CodePipeline(mock_session), + deployment=ECS( + ecs_client=mock_ecs, + ssm_client=mock_ssm, + application_name="test-application", + env=input_args["env"], + ), + file_system=LocalFileSystem(), + ) + + result = cb.redeploy(**input_args) + + assert result[0].codebase == "application" + assert result[0].pipeline == "test-application-application-manual-release" + assert result[0].execution_id == "doesnt-matter-id" + assert result[0].tag == "commit-id" + assert not result[0].error + if input_args.get("wait", True): + assert result[0].status == "succeeded" + else: + assert result[0].status == "triggered" + assert ( + result[0].url + == "https://eu-west-2.console.aws.amazon.com/codesuite/codepipeline/pipelines/test-application-application-manual-release/executions/doesnt-matter-id" + )