From 87b5f17bfe621061194cbe06ffa85108fcb76696 Mon Sep 17 00:00:00 2001 From: alafanechere Date: Thu, 23 Jan 2025 15:44:29 +0100 Subject: [PATCH 1/2] augustin --- poetry.lock | 31 ++++++++++++++++++ pyproject.toml | 19 +++++++++++ src/paas_coding_interview/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 182 bytes .../__pycache__/cli.cpython-313.pyc | Bin 0 -> 811 bytes src/paas_coding_interview/argo_mocks.py | 5 +++ src/paas_coding_interview/cli.py | 26 +++++++++++++++ src/paas_coding_interview/datadog_mocks.py | 8 +++++ 8 files changed, 89 insertions(+) create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/paas_coding_interview/__init__.py create mode 100644 src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc create mode 100644 src/paas_coding_interview/__pycache__/cli.cpython-313.pyc create mode 100644 src/paas_coding_interview/argo_mocks.py create mode 100644 src/paas_coding_interview/cli.py create mode 100644 src/paas_coding_interview/datadog_mocks.py diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..d9b1aa4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,31 @@ +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.13" +content-hash = "03d36676e7b51539deff450f5d0372b765834c6f934db6cc497f5ea66e132e3c" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e912cc9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "paas-coding-interview" +version = "0.1.0" +description = "" +authors = ["alafanechere "] +readme = "README.md" +packages = [{ include = "paas_coding_interview", from = "src" }] + +[tool.poetry.dependencies] +python = "^3.13" +click = "^8.1.8" + +[tool.poetry.scripts] +paas = "paas_coding_interview.cli:paas" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/paas_coding_interview/__init__.py b/src/paas_coding_interview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc b/src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7566837fb2bd375853969d2b022bd92b1f315dd2 GIT binary patch literal 182 zcmey&%ge<81Tr3z(n0iN5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa|LoerR!OQL%nv zX?kgKNoJmYd45rLaY15os(wLYVzF*=eoAIux^8A(NorA9W@@>9aZxf@CLSUakC2H6 pvNKEKRel#-W1q}(*XWw}smmI0 z$jYwLORHOR%?B?5II?tUrC|pG5)jAwtw}n5MGatJqQCqensa!y#Qr~(OG+ik2@dTj z^NAP?5{D|&AB28iG2y>SK0&UD)brMhOvtRoN5Lo?hH-10$bOb`U$j!rvpqlQg>kSK z#zP_BhGN{xq+cN39Pugy+3%)Dis?S7x1?2PSJ529FSasmPugeWFYJcYFR1D~O;HXX zhDq$jJQDum0TBey3oC}+!~ln0V&zj7!;y|d)aq*~ct1^0h(qKRtj~1yj$A>jte!{* z$&u7D^j Date: Thu, 23 Jan 2025 17:36:23 +0100 Subject: [PATCH 2/2] make it better --- .gitignore | 42 +++++++++++ README.md | 11 +++ .../__pycache__/__init__.cpython-313.pyc | Bin 182 -> 0 bytes .../__pycache__/cli.cpython-313.pyc | Bin 811 -> 0 bytes src/paas_coding_interview/argo_mocks.py | 12 ++- src/paas_coding_interview/cli.py | 36 ++++++--- src/paas_coding_interview/datadog_mocks.py | 46 ++++++++++-- src/paas_coding_interview/errors.py | 50 +++++++++++++ src/paas_coding_interview/models.py | 39 ++++++++++ .../notification_mocks.py | 11 +++ src/paas_coding_interview/registries.py | 70 ++++++++++++++++++ 11 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 .gitignore delete mode 100644 src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/paas_coding_interview/__pycache__/cli.cpython-313.pyc create mode 100644 src/paas_coding_interview/errors.py create mode 100644 src/paas_coding_interview/models.py create mode 100644 src/paas_coding_interview/notification_mocks.py create mode 100644 src/paas_coding_interview/registries.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d71c86d --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Virtual environments +venv/ +env/ +.env/ +.venv/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Logs +*.log + +# Local development settings +.env +.env.local + +# Jupyter Notebook +.ipynb_checkpoints + +# mypy +.mypy_cache/ + +# pytest +.pytest_cache/ diff --git a/README.md b/README.md index e049f84..3a6fa37 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,14 @@ You must: * https://docs.datadoghq.com/api/latest/downtimes/ * https://cd.apps.argoproj.io/swagger-ui + +## Potential improvements + +* Add a logging system instead of click.echo, improve error logging +* Add unit tests +* Use real API calls (ArgoCD REST API, DD Python SDK) +* Check availability of the dependencies (Datadog, ArgoCD) before running the CLI and handle errors gracefully +* Add a timeout for each step to avoid long running requests +* Create a github action workflow to automate the CLI run +* Instrument the CLI with telemetry to audit the runs +* Add a configuration system to allow the CLI to be configured with environment variables \ No newline at end of file diff --git a/src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc b/src/paas_coding_interview/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 7566837fb2bd375853969d2b022bd92b1f315dd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmey&%ge<81Tr3z(n0iN5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa|LoerR!OQL%nv zX?kgKNoJmYd45rLaY15os(wLYVzF*=eoAIux^8A(NorA9W@@>9aZxf@CLSUakC2H6 pvNKEKRel#-W1q}(*XWw}smmI0 z$jYwLORHOR%?B?5II?tUrC|pG5)jAwtw}n5MGatJqQCqensa!y#Qr~(OG+ik2@dTj z^NAP?5{D|&AB28iG2y>SK0&UD)brMhOvtRoN5Lo?hH-10$bOb`U$j!rvpqlQg>kSK z#zP_BhGN{xq+cN39Pugy+3%)Dis?S7x1?2PSJ529FSasmPugeWFYJcYFR1D~O;HXX zhDq$jJQDum0TBey3oC}+!~ln0V&zj7!;y|d)aq*~ct1^0h(qKRtj~1yj$A>jte!{* z$&u7D^j tuple[bool, str]: + """ + Mock Argo CD sync operation + Returns ArgoSyncStatus with success/failure and message + """ + if application_name == "failing-app": + return False, "Failed to sync application, but that's not a real thing, it's just a mock" + + return True, "Successfully synced application, but that's not a real thing, it's just a mock" diff --git a/src/paas_coding_interview/cli.py b/src/paas_coding_interview/cli.py index 45eea68..ff4c1a2 100644 --- a/src/paas_coding_interview/cli.py +++ b/src/paas_coding_interview/cli.py @@ -1,26 +1,38 @@ import click -from pass_coding_interview import datadog_mocks, argo_mocks +from paas_coding_interview import datadog_mocks, argo_mocks, errors +from paas_coding_interview.registries import registry @click.group() def paas(): - click.echo("Hello") + pass @paas.command() @click.argument('application_name') def deploy(application_name: str): + # Validate application exists in registry + application = registry.get_application(application_name) + # We don't want to deploy if the application has an existing downtime + if application.has_datadog_downtime(): + raise errors.ApplicationIsDownError(application) + + # We can schedule a downtime for the application has it does not have an existing downtime + application.schedule_datadog_downtime() + click.echo("Datadog downtime scheduled") - # set datadog downtime for the application - is_down = get_downtimes(application_name) - if is_down: - raise ApplicationIsAlreadyDownError() - downtime_id = schedule_downtime(application_name) - deployed = argo_mocks.sync(application_name) - unset_downtime(downtime_id) - - # tigger argo cd with the application name - # unset datadog downtime + # Sync the application with Argo CD - aka deploy the application + try: + sync_status = application.argo_sync() + if not sync_status.success: + raise errors.ApplicationSyncError(application) + finally: + # Unset the downtime in any case + # We assume Argo CD is self-healing + application.unset_datadog_downtime() + click.echo("Datadog downtime unset") + + click.echo(sync_status.message) if __name__ == '__main__': paas() \ No newline at end of file diff --git a/src/paas_coding_interview/datadog_mocks.py b/src/paas_coding_interview/datadog_mocks.py index 700b325..c159e55 100644 --- a/src/paas_coding_interview/datadog_mocks.py +++ b/src/paas_coding_interview/datadog_mocks.py @@ -1,8 +1,42 @@ -def get_downtimes(): - pass +# Mock data to simulate Datadog API responses +# We assume that Datadog API can give us downtime IDs from a specific application name -def schedule_downtime(): - pass +from paas_coding_interview.models import Downtime -def cancel_downtime(): - pass \ No newline at end of file +# Simulate a set of active downtimes that would be DD source of truth +_active_downtimes = set() + + +def get_downtime(application_name: str) -> Downtime: + """ + Mock checking if an application has an active downtime + Returns a Downtime object with active status and optional ID + """ + # Simulate an application that's already in downtime + if application_name == "app-in-downtime": + return Downtime(active=True, downtime_id="mock-downtime-123") + + # Default case - no active downtime + return Downtime(active=False) + +def has_downtime(application_name: str) -> bool: + """ + Check if application has an active downtime + Returns True if downtime exists, False otherwise + """ + return get_downtime(application_name).active + +def schedule_downtime(application_name: str) -> Downtime: + """ + Schedule a downtime for an application + Returns a Downtime object with the new downtime details + """ + _active_downtimes.add(application_name) + return Downtime(active=True, downtime_id="mock-downtime-456") + +def unset_downtime(downtime_id: str) -> Downtime: + """ + Remove a downtime using its ID + Returns a Downtime object with the updated status + """ + return Downtime(active=False) \ No newline at end of file diff --git a/src/paas_coding_interview/errors.py b/src/paas_coding_interview/errors.py new file mode 100644 index 0000000..47290eb --- /dev/null +++ b/src/paas_coding_interview/errors.py @@ -0,0 +1,50 @@ +import os +from functools import wraps +from typing import Type, Callable +from paas_coding_interview.notification_mocks import send_slack_message + +def notify_error_in_ci(error_channel: str = "#deployments-errors") -> Callable: + """ + Decorator factory that sends notifications to Slack when in CI environment + before re-raising the original error + """ + def decorator(error_class: Type[Exception]) -> Type[Exception]: + if not os.getenv("CI"): + return error_class + + class NotifiedError(error_class): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + send_slack_message( + channel=error_channel, + message=f"Error in CI: {str(self)}" + ) + + NotifiedError.__name__ = error_class.__name__ + return NotifiedError + + return decorator + +# Example usage: +@notify_error_in_ci() +class ApplicationNotFoundError(Exception): + """Raised when application is not found in registry""" + def __init__(self, application_name: str = None): + message = f"Application '{application_name}' not found in registry" if application_name else "Application not found in registry" + super().__init__(message) + +@notify_error_in_ci() +class ApplicationIsDownError(Exception): + """Raised when trying to deploy an application that is down""" + def __init__(self, application): + message = f"Application '{application.name}' is currently down. Could not deploy." + if application.downtime and application.downtime.downtime_id: + message += f" (downtime_id: {application.downtime.downtime_id})" + super().__init__(message) + +@notify_error_in_ci() +class ApplicationSyncError(Exception): + """Raised when trying to sync an application that failed""" + def __init__(self, application): + message = f"Application '{application.name}' failed to sync to Argo CD. Could not deploy." + super().__init__(message) \ No newline at end of file diff --git a/src/paas_coding_interview/models.py b/src/paas_coding_interview/models.py new file mode 100644 index 0000000..cb6c921 --- /dev/null +++ b/src/paas_coding_interview/models.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Optional + +from paas_coding_interview import datadog_mocks +from paas_coding_interview import argo_mocks + + +@dataclass +class ArgoSyncStatus: + success: bool + message: str + +@dataclass +class Downtime: + active: bool + downtime_id: Optional[str] = None + +@dataclass +class Application: + name: str + namespace: str + project: str + sync_status: Optional[str] = None + health_status: Optional[str] = None + downtime: Optional[Downtime] = None + + def has_datadog_downtime(self) -> bool: + return datadog_mocks.has_downtime(self.name) + + def schedule_datadog_downtime(self) -> Downtime: + return datadog_mocks.schedule_downtime(self.name) + + def unset_datadog_downtime(self) -> None: + if not self.downtime: + raise ValueError("Downtime ID is not set") + return datadog_mocks.unset_downtime(self.downtime) + + def argo_sync(self) -> ArgoSyncStatus: + return ArgoSyncStatus(*argo_mocks.sync(self.name)) diff --git a/src/paas_coding_interview/notification_mocks.py b/src/paas_coding_interview/notification_mocks.py new file mode 100644 index 0000000..c7b2cdb --- /dev/null +++ b/src/paas_coding_interview/notification_mocks.py @@ -0,0 +1,11 @@ +def send_slack_message(channel: str, message: str) -> bool: + """ + Mock sending a Slack message to a channel + Returns True if message was sent successfully, False otherwise + + For testing: Messages to 'invalid-channel' will fail + """ + if channel == "invalid-channel": + return False + return True + diff --git a/src/paas_coding_interview/registries.py b/src/paas_coding_interview/registries.py new file mode 100644 index 0000000..64c2718 --- /dev/null +++ b/src/paas_coding_interview/registries.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +import json +from typing import List +from paas_coding_interview.models import Application +from paas_coding_interview.datadog_mocks import get_downtime + +class ApplicationNotFoundError(Exception): + """Raised when application is not found in registry""" + pass + +class ApplicationRegistry: + def __init__(self, applications: List[Application]): + self.applications = applications + + @classmethod + def from_argo(cls, repo_path: str): + # Mock response from /api/v1/applications + mock_argo_response = { + "items": [ + { + "metadata": { + "name": "backend", + "namespace": "production", + }, + "spec": { + "project": "main" + }, + "status": { + "sync": {"status": "Synced"}, + "health": {"status": "Healthy"} + } + }, + { + "metadata": { + "name": "failing-app", + "namespace": "production", + }, + "spec": { + "project": "main" + }, + "status": { + "sync": {"status": "OutOfSync"}, + "health": {"status": "Degraded"} + } + } + ] + } + + applications = [] + for app_data in mock_argo_response["items"]: + app = Application( + name=app_data["metadata"]["name"], + namespace=app_data["metadata"]["namespace"], + project=app_data["spec"]["project"], + sync_status=app_data["status"]["sync"]["status"], + health_status=app_data["status"]["health"]["status"], + downtime=get_downtime(app_data["metadata"]["name"]) + ) + applications.append(app) + + return cls(applications) + + def get_application(self, application_name: str) -> Application: + for app in self.applications: + if app.name == application_name: + return app + raise ApplicationNotFoundError(f"Application '{application_name}' not found in registry") + +# Create a singleton instance of the registry +registry = ApplicationRegistry.from_argo("mock/path")