Skip to content

Commit

Permalink
feat: contract based deployments (#379)
Browse files Browse the repository at this point in the history
* feat: contract based deployments

adds support in the cli for running deployments
using contracts instead of bash scripts

This allows for the engine to control how the
deployment is run and also lets the python
executor look after making it happen

Current implementation handles cloudformation
deployments using either Change Sets or standard
stack updates. Using standard stack updates should
reduce some of the processing time when running
deployments that don't need change sets.

Also supports presigned url hosting of
cloudformation templates so that we can use
larger template sizes

To make the bites to chomp off smaller there is a
utility function which calls the bash executor
to return the cf_dir so that we don't have to
fully replicate the context lookup to get this
going

* feat: extend file management and cfn stacks

* chore: formatting updates

* fix: test mocks

* fix: test mocks

* chore: flake fixup

* fix: disable the cli pager in commands
  • Loading branch information
roleyfoley authored Apr 4, 2024
1 parent 392a648 commit d88000a
Show file tree
Hide file tree
Showing 28 changed files with 696 additions and 251 deletions.
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.11.1
3.11.7
46 changes: 46 additions & 0 deletions hamlet/backend/common/aws_cfn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from hamlet.backend.common.exceptions import BackendException


class CloudFormationStackException(BackendException):

def __init__(self, stack_name, cfn_client, client_request_token):
self.stack_name = stack_name

stack_events = [
"* "
+ " - ".join(
[
stack_event["LogicalResourceId"],
stack_event["ResourceType"],
stack_event["ResourceStatus"],
stack_event.get("ResourceStatusReason", ""),
]
)
for sublist in [
x["StackEvents"]
for x in cfn_client.get_paginator("describe_stack_events").paginate(
StackName=self.stack_name
)
]
for stack_event in sublist
if (
stack_event.get("ClientRequestToken")
and stack_event["ClientRequestToken"] == client_request_token
and stack_event["ResourceStatus"]
in [
"CREATE_FAILED",
"DELETE_FAILED",
"UPDATE_FAILED",
"UPDATE_ROLLBACK_FAILED",
"UPDATE_ROLLBACK_COMPLETE",
"ROLLBACK_FAILED",
"ROLLBACK_COMPLETE",
]
)
]

super().__init__(
msg="Cloudformation failed to update the stack\n\n"
+ "\n".join(stack_events)
+ "\n"
)
6 changes: 3 additions & 3 deletions hamlet/backend/common/aws_credentials_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ def boto3_session(
token=existing_creds["aws_session_token"],
)

session.get_component("credential_provider").get_provider(
"assume-role"
).cache = credentials.JSONFileCache(cli_cache)
session.get_component("credential_provider").get_provider("assume-role").cache = (
credentials.JSONFileCache(cli_cache)
)

return boto3.Session(botocore_session=session)
16 changes: 12 additions & 4 deletions hamlet/backend/common/runner.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import os
import subprocess
import shutil
import subprocess

from .exceptions import BackendException


def __cli_params_to_script_call(
script_path, script_name, args=None, options=None, extra_script=None
script_path, script_name, args=None, options=None, extra_script=None, source=False
):
args = list(arg for arg in (args if args is not None else []) if arg is not None)
options_list = []
Expand All @@ -23,9 +23,15 @@ def __cli_params_to_script_call(
options_list.append(str(key))
options_list.append(str(f"'{value}'"))
script_fullpath = os.path.join(script_path, script_name)

script_call_parts = [script_fullpath] + options_list + args

if extra_script is not None:
return " ".join([".", script_fullpath] + options_list + args + [extra_script])
return " ".join([script_fullpath] + options_list + args)
script_call_parts += [extra_script]
if source:
script_call_parts = ["."] + script_call_parts

return " ".join(script_call_parts)


def __env_params_to_envvars(env=None):
Expand All @@ -50,6 +56,7 @@ def run(
_is_cli,
script_base_path_env="GENERATION_DIR",
extra_script=None,
source=False,
):
env_overrides = {
**os.environ,
Expand All @@ -76,6 +83,7 @@ def run(
args=args,
options=options,
extra_script=extra_script,
source=source,
)
process = subprocess.Popen(
[shutil.which("bash"), "-c", script_call_line],
Expand Down
3 changes: 2 additions & 1 deletion hamlet/backend/contract/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import importlib

import click

from hamlet.backend.contract.tasks.exceptions import (
TaskConditionFailureException,
TaskFailureException,
Expand Down Expand Up @@ -47,7 +49,6 @@ def run(contract, silent, engine, env):
replaced_params["env"] = {**engine.environment, **env}
try:
task_result = task.run(**replaced_params)

try:
for k, v in task_result["Properties"].items():
properties[f"output:{step['Id']}:{k}"] = v
Expand Down
104 changes: 104 additions & 0 deletions hamlet/backend/contract/tasks/aws_cfn_create_change_set/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import datetime
from typing import Optional

import boto3
import botocore


def run(
TemplateS3Uri=None,
TemplateBody=None,
StackName=None,
ChangeSetName=None,
Parameters=None,
Capabilities: Optional[str] = None,
Region: Optional[str] = None,
AWSAccessKeyId: Optional[str] = None,
AWSSecretAccessKey: Optional[str] = None,
AWSSessionToken: Optional[str] = None,
env={},
):
"""
Create a CloudFormation ChangeSet
"""

session = boto3.Session(
aws_access_key_id=AWSAccessKeyId,
aws_secret_access_key=AWSSecretAccessKey,
aws_session_token=AWSSessionToken,
region_name=Region,
)

cfn = session.client("cloudformation")

client_token = str(int(datetime.datetime.now().timestamp()))

existing_stacks = [
stack
for sublist in [
x["StackSummaries"] for x in cfn.get_paginator("list_stacks").paginate()
]
for stack in sublist
if stack["StackName"] == StackName
]

for stack in [
stack for stack in existing_stacks if stack["StackStatus"] == "CREATE_FAIlED"
]:
cfn.delete_stack(StackName=stack["StackName"])
cfn.get_waiter("stack_delete_complete").wait(StackName=stack["StackName"])

change_set_params = {
"StackName": StackName,
"ChangeSetName": ChangeSetName,
"ChangeSetType": "CREATE",
"ClientToken": client_token,
}

if TemplateS3Uri:
change_set_params["TemplateURL"] = TemplateS3Uri
elif TemplateBody:
change_set_params["TemplateBody"] = TemplateBody

if (
len(
[
stack
for stack in existing_stacks
if stack["StackStatus"] not in ["DELETE_COMPLETE", "CREATE_FAILED"]
]
)
> 0
):
change_set_params["ChangeSetType"] = "UPDATE"

if Parameters:
change_set_params["Parameters"] = [
{"ParameterKey": k, "ParameterValue": v} for k, v in Parameters.items()
]

if Capabilities:
change_set_params["Capabilities"] = Capabilities.split(",")

cfn.create_change_set(**change_set_params)
changes_required = True

try:
cfn.get_waiter("change_set_create_complete").wait(
ChangeSetName=ChangeSetName,
StackName=StackName,
)
except botocore.exceptions.WaiterError as error:
change_set_state = cfn.describe_change_set(
ChangeSetName=ChangeSetName, StackName=StackName
)
if (
change_set_state["Status"] == "FAILED"
and change_set_state["StatusReason"]
== "The submitted information didn't contain changes. Submit different information to create a change set."
):
changes_required = False
else:
raise error

return {"Properties": {"changes_required": str(changes_required)}}
79 changes: 0 additions & 79 deletions hamlet/backend/contract/tasks/aws_cfn_create_changeset/__init__.py

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import datetime

import boto3


def run(
StackName=None,
RunId=None,
Region=None,
AWSAccessKeyId=None,
AWSSecretAccessKey=None,
Expand All @@ -21,9 +22,12 @@ def run(
region_name=Region,
)

client_request_token = str(int(datetime.datetime.now().timestamp()))

cfn = session.client("cloudformation")
cfn.delete_stack(
StackName=StackName, ClientRequestToken=f"hamlet_{StackName}_{RunId}"
StackName=StackName,
ClientRequestToken=client_request_token,
)

cfn.get_waiter("stack_delete_complete").wait(StackName=StackName)
Expand Down
Loading

0 comments on commit d88000a

Please sign in to comment.