diff --git a/appconf/__init__.py b/appconf/__init__.py index 485f44a..d3ec452 100644 --- a/appconf/__init__.py +++ b/appconf/__init__.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/appconf/api.py b/appconf/api.py index d836c04..251a2ca 100644 --- a/appconf/api.py +++ b/appconf/api.py @@ -1,14 +1,21 @@ import logging -from appconf.models import Application, ConfigurationProfile, HostedConfigurationVersion +from appconf.models import ( + Application, + ConfigurationProfile, + HostedConfigurationVersion, + Deployment, + DeploymentStrategy, + Environment, +) from appconf.exceptions import NoHostedConfigurationVersionsFound -def get_application(appconfig_client, app_name): +def get_application(client, app_name): """ Get the application id from the application name """ - apps = appconfig_client.list_applications() + apps = client.list_applications() for a in apps["Items"]: if a["Name"] == app_name: @@ -17,11 +24,11 @@ def get_application(appconfig_client, app_name): return None -def get_config_profile(appconfig_client, app_id, profile_name): +def get_config_profile(client, app_id, profile_name): """ Get the configuration profile from the application id and profile name """ - profiles = appconfig_client.list_configuration_profiles(ApplicationId=app_id) + profiles = client.list_configuration_profiles(ApplicationId=app_id) for p in profiles["Items"]: if p["Name"] == profile_name: @@ -30,13 +37,11 @@ def get_config_profile(appconfig_client, app_id, profile_name): return None -def get_latest_hosted_configuration_version( - appconfig_client, application, config_profile -): +def get_latest_hosted_configuration_version(client, application, config_profile): """ Get the latest hosted configuration version from the configuration profile id """ - config_versions = appconfig_client.list_hosted_configuration_versions( + config_versions = client.list_hosted_configuration_versions( ApplicationId=application.Id, ConfigurationProfileId=config_profile.Id ) @@ -52,7 +57,7 @@ def get_latest_hosted_configuration_version( if v["VersionNumber"] > latest_version["VersionNumber"]: latest_version = v - latest_hosted_config = appconfig_client.get_hosted_configuration_version( + latest_hosted_config = client.get_hosted_configuration_version( ApplicationId=application.Id, ConfigurationProfileId=config_profile.Id, VersionNumber=latest_version["VersionNumber"], @@ -112,3 +117,50 @@ def setup(client, app_name, profile_name): return return application, config_profile + + +def get_deployment_strategy(client, strategy_name): + """ + Get all deployment strategies + """ + strategies = client.list_deployment_strategies() + + for p in strategies["Items"]: + if p["Name"] == strategy_name: + return DeploymentStrategy.from_dict(p) + + return None + + +def get_environment(client, application, environment_name): + environments = client.list_environments(ApplicationId=application.Id) + + for p in environments["Items"]: + if p["Name"] == environment_name: + return Environment.from_dict(p) + + return None + + +def start_deployment( + client, application, config_profile, deployment_strategy, environment +): + deployment_response = client.start_deployment( + ApplicationId=application.Id, + ConfigurationProfileId=config_profile.Id, + ConfigurationVersion="1", + DeploymentStrategyId=deployment_strategy.Id, + EnvironmentId=environment.Id, + ) + + return Deployment.from_dict(deployment_response) + + +def get_deployment(client, application, environment, deployment_number): + deployment_response = client.get_deployment( + ApplicationId=application.Id, + DeploymentNumber=deployment_number, + EnvironmentId=environment.Id, + ) + + return Deployment.from_dict(deployment_response) diff --git a/appconf/main.py b/appconf/main.py index 22b4fed..0077df3 100644 --- a/appconf/main.py +++ b/appconf/main.py @@ -5,23 +5,26 @@ import logging import os import sys +import time import boto3 import click from rich.console import Console from rich.logging import RichHandler +from rich.progress import Progress from rich.rule import Rule -from appconf import api +from appconf import api, __version__ from appconf.exceptions import NoHostedConfigurationVersionsFound -logging.basicConfig(level=logging.INFO, handlers=[RichHandler()]) +logging.basicConfig(level=logging.CRITICAL, handlers=[RichHandler()]) console = Console() @click.group() @click.pass_context +@click.version_option(__version__) @click.option( "--aws-profile", help="AWS Profile Name", @@ -76,7 +79,18 @@ def get_config(ctx, app, profile, meta): @click.option("-a", "--app", help="Application Name", required=True) @click.option("-p", "--profile", help="Configuration profile name", required=True) @click.option("-d", "--description", help="Description for the version", default="") -def put_config(ctx, config_file, app, profile, description): +@click.option( + "--deploy", is_flag=True, help="Deploy to configuration environment", default=False +) +@click.option( + "--deploy-strategy", + help="Deploy using named strategy", + default="AppConfig.Linear50PercentEvery30Seconds", +) +@click.option("--env", help="Deploy to environment", default="default") +def put_config( + ctx, config_file, app, profile, description, deploy, deploy_strategy, env +): """ Upload a new hosted configuration version for the application & profile """ @@ -100,6 +114,47 @@ def put_config(ctx, config_file, app, profile, description): console.print(f"[green]Created new configuration version:[/green] {version}") + if deploy: + strategy = api.get_deployment_strategy(ctx.obj["appconfig"], deploy_strategy) + + if strategy is None: + console.print( + f"[red]Unable to deploy configuration profile using strategy {strategy} " + f"for {application.Name} ({application.Id})![/red]" + ) + return + + environment = api.get_environment(ctx.obj["appconfig"], application, env) + + deployment = api.start_deployment( + ctx.obj["appconfig"], application, config_profile, strategy, environment + ) + + with Progress() as progress: + task = progress.add_task("Deployment in progress...", total=100) + complete_so_far = 0 + + while deployment.PercentageComplete != 100.0: + time.sleep(20) + deployment = api.get_deployment( + ctx.obj["appconfig"], + application, + environment, + deployment.DeploymentNumber, + ) + percent_complete = int(deployment.PercentageComplete) + advance = percent_complete - complete_so_far + progress.update(task, advance=advance) + complete_so_far = percent_complete + + progress.update(task, visible=False) + + console.print( + f"[green]Successful deployment to [/green] {env} [green](took " + f"{deployment.FinalBakeTimeInMinutes} minute" + f"{'s' if deployment.FinalBakeTimeInMinutes > 1 else ''})[/green]" + ) + if __name__ == "__main__": cli(obj={}) diff --git a/appconf/models.py b/appconf/models.py index 8ac4fa1..fc6c559 100644 --- a/appconf/models.py +++ b/appconf/models.py @@ -34,3 +34,32 @@ class HostedConfigurationVersion(Base): def get_json(self): return self.Content.read().decode("utf-8") + + +@dataclasses.dataclass +class DeploymentStrategy(Base): + Id: str + Name: str + DeploymentDurationInMinutes: int + GrowthType: str + + +@dataclasses.dataclass +class Environment(Base): + ApplicationId: str + Id: str + Name: str + State: str + + +@dataclasses.dataclass +class Deployment(Base): + ApplicationId: str + EnvironmentId: str + DeploymentStrategyId: str + ConfigurationProfileId: str + DeploymentNumber: int + DeploymentDurationInMinutes: int + FinalBakeTimeInMinutes: int + State: str + PercentageComplete: float diff --git a/poetry.lock b/poetry.lock index 31dbcbc..88819bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. [[package]] name = "black" version = "23.7.0" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -50,6 +51,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "boto3" version = "1.28.9" description = "The AWS SDK for Python" +category = "main" optional = false python-versions = ">= 3.7" files = [ @@ -69,6 +71,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] name = "botocore" version = "1.31.9" description = "Low-level, data-driven core of boto 3." +category = "main" optional = false python-versions = ">= 3.7" files = [ @@ -88,6 +91,7 @@ crt = ["awscrt (==0.16.26)"] name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -99,6 +103,7 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -113,6 +118,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -124,6 +130,7 @@ files = [ name = "distlib" version = "0.3.6" description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" files = [ @@ -135,6 +142,7 @@ files = [ name = "exceptiongroup" version = "1.1.2" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -149,6 +157,7 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.12.2" description = "A platform independent file lock." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -164,6 +173,7 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p name = "identify" version = "2.5.24" description = "File identification library for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -178,6 +188,7 @@ license = ["ukkonen"] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -185,10 +196,23 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "invoke" +version = "2.2.0" +description = "Pythonic task execution" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820"}, + {file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"}, +] + [[package]] name = "jmespath" version = "1.0.1" description = "JSON Matching Expressions" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -200,6 +224,7 @@ files = [ name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -224,6 +249,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -235,6 +261,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -246,6 +273,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -260,6 +288,7 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -271,6 +300,7 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -282,6 +312,7 @@ files = [ name = "platformdirs" version = "3.8.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -297,6 +328,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -312,6 +344,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "3.3.3" description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -330,6 +363,7 @@ virtualenv = ">=20.10.0" name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -344,6 +378,7 @@ plugins = ["importlib-metadata"] name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -366,6 +401,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -380,6 +416,7 @@ six = ">=1.5" name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -429,6 +466,7 @@ files = [ name = "rich" version = "13.4.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -447,6 +485,7 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "ruff" version = "0.0.277" description = "An extremely fast Python linter, written in Rust." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -473,6 +512,7 @@ files = [ name = "s3transfer" version = "0.6.1" description = "An Amazon S3 Transfer Manager" +category = "main" optional = false python-versions = ">= 3.7" files = [ @@ -490,6 +530,7 @@ crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -506,6 +547,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -517,6 +559,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -528,6 +571,7 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -539,6 +583,7 @@ files = [ name = "urllib3" version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -555,6 +600,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "virtualenv" version = "20.23.1" description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -574,4 +620,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "04ec335708547cc081eb4f899d51ef33efe41ed3b107125cd71f3b49d056e647" +content-hash = "146eca234bd4754e9bc70322ad753ea94184f63079c6eb43800b2675efb89c66" diff --git a/pyproject.toml b/pyproject.toml index 611b4a3..4a7b5a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "appconfig-cli" -version = "0.1.1" +version = "0.2.0" description = "Unofficial CLI tool for working with AWS AppConfig" packages = [{include = "appconf"}] authors = ["Nick Snell "] @@ -32,6 +32,7 @@ pytest = "^7.4.0" ruff = "^0.0.277" black = "^23.3.0" pre-commit = "^3.3.3" +invoke = "2.2.0" [tool.poetry.scripts] diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..b0e8227 --- /dev/null +++ b/tasks.py @@ -0,0 +1,12 @@ +from invoke import task + + +@task +def lint(c): + c.run("black appconf tests") + c.run("ruff appconf tests") + + +@task +def test(c): + c.run("pytest") diff --git a/tests/conftest.py b/tests/conftest.py index 67002e1..2f7c5bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,13 @@ import json import pytest -from appconf.models import Application, ConfigurationProfile, HostedConfigurationVersion +from appconf.models import ( + Application, + ConfigurationProfile, + HostedConfigurationVersion, + Environment, + DeploymentStrategy, +) @pytest.fixture @@ -24,6 +30,16 @@ def config_profile_id(): return "config-profile-id" +@pytest.fixture +def environment_id(): + return "env-id" + + +@pytest.fixture +def mock_deployment_strategy_id(): + return "AppConfig.Linear50PercentEvery30Seconds" + + @pytest.fixture def mock_api_list_applications(app_name, app_id): return { @@ -70,6 +86,50 @@ def mock_api_list_hosted_configuration_versions(app_id, config_profile_id): } +@pytest.fixture +def mock_api_list_deployment_strategies(mock_deployment_strategy_id): + return { + "Items": [ + { + "Id": mock_deployment_strategy_id, + "Name": mock_deployment_strategy_id, + "DeploymentDurationInMinutes": 30, + "GrowthType": "LINEAR", + } + ] + } + + +@pytest.fixture +def mock_api_list_environments(app_id, environment_id): + return { + "Items": [ + { + "ApplicationId": app_id, + "Id": environment_id, + "Name": "default", + "State": "READY_FOR_DEPLOYMENT", + } + ] + } + + +@pytest.fixture +def mock_api_deployment(app_id, config_profile_id, environment_id): + return { + "ApplicationId": app_id, + "EnvironmentId": environment_id, + "DeploymentStrategyId": "AppConfig.Linear50PercentEvery30Seconds", + "ConfigurationProfileId": config_profile_id, + "DeploymentNumber": 1, + "ConfigurationVersion": "1", + "DeploymentDurationInMinutes": 30, + "FinalBakeTimeInMinutes": 0, + "State": "DEPLOYING", + "PercentageComplete": 0.0, + } + + @pytest.fixture def mock_application(mock_api_list_applications): return Application.from_dict(mock_api_list_applications["Items"][0]) @@ -87,3 +147,13 @@ def mock_latest_config_profile(mock_api_list_hosted_configuration_versions): return HostedConfigurationVersion.from_dict( mock_api_list_hosted_configuration_versions["Items"][0] ) + + +@pytest.fixture +def mock_deployment_strategy(mock_api_list_deployment_strategies): + return DeploymentStrategy.from_dict(mock_api_list_deployment_strategies["Items"][0]) + + +@pytest.fixture +def mock_environment(mock_api_list_environments): + return Environment.from_dict(mock_api_list_environments["Items"][0]) diff --git a/tests/test_api.py b/tests/test_api.py index 143d200..7711b43 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,6 +4,10 @@ get_application, get_config_profile, get_latest_hosted_configuration_version, + get_deployment_strategy, + get_environment, + get_deployment, + start_deployment, create_configuration, setup as api_setup, ) @@ -76,6 +80,63 @@ def test_create_configuration( assert version == 2 +def test_get_deployment_strategy( + mock_api_list_deployment_strategies, mock_deployment_strategy_id +): + client = Mock() + client.list_deployment_strategies.return_value = mock_api_list_deployment_strategies + + deployment_strategy = get_deployment_strategy(client, mock_deployment_strategy_id) + + assert deployment_strategy.Id == mock_deployment_strategy_id + + +def test_get_environment(mock_api_list_environments, mock_application): + client = Mock() + client.list_environments.return_value = mock_api_list_environments + + env_name = "default" + + env = get_environment(client, mock_application, env_name) + + assert env.Name == env_name + assert env.State == "READY_FOR_DEPLOYMENT" + + +def test_start_deployment( + mock_api_deployment, + mock_application, + mock_config_profile, + mock_deployment_strategy, + mock_environment, +): + client = Mock() + client.start_deployment.return_value = mock_api_deployment + + deployment = start_deployment( + client, + mock_application, + mock_config_profile, + mock_deployment_strategy, + mock_environment, + ) + + assert deployment.ApplicationId == mock_application.Id + assert deployment.EnvironmentId == mock_environment.Id + assert deployment.DeploymentNumber == 1 + + +def test_get_deployment(mock_api_deployment, mock_application, mock_environment): + client = Mock() + client.get_deployment.return_value = mock_api_deployment + + deployment = get_deployment(client, mock_application, mock_environment, 1) + + assert deployment.ApplicationId == mock_application.Id + assert deployment.EnvironmentId == mock_environment.Id + assert deployment.DeploymentNumber == 1 + + @patch("appconf.api.get_application") @patch("appconf.api.get_config_profile") def test_setup(