Skip to content

Commit

Permalink
#43: Handle in-file invalidation configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
garyd203 committed Nov 12, 2020
1 parent 78e7752 commit cca3933
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 2 deletions.
60 changes: 58 additions & 2 deletions src/ssmash/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@
from functools import partial
from functools import wraps
from typing import Callable
from typing import Dict
from typing import List

import click
import yaml
from flyingcircus.core import Resource
from flyingcircus.core import Stack
from flyingcircus.service.ssm import SSMParameter

from ssmash.config import InvalidatingConfigKey
from ssmash.converter import convert_hierarchy_to_ssm
from ssmash.invalidation import create_lambda_invalidation_stack
from ssmash.loader import EcsServiceInvalidator
from ssmash.loader import get_cfn_resource_from_options
from ssmash.util import clean_logical_name
from ssmash.yamlhelper import SsmashYamlLoader

# TODO move helper functions to another module
# TODO tests for helper functions
Expand Down Expand Up @@ -66,6 +72,7 @@ def process_pipeline(processors, input_file, output_file, description: str):
processors = (
[_create_ssm_parameters]
+ processors
+ [_create_embedded_invalidations]
+ [partial(_write_cfn_template, output_file)]
)

Expand Down Expand Up @@ -232,11 +239,60 @@ def invalidate_lambda(

def _create_ssm_parameters(appconfig: dict, stack: Stack):
"""Create SSM parameters for every item in the application configuration"""
clean_config = dict(appconfig)
clean_config.pop(".ssmash-config", None)
stack.merge_stack(
convert_hierarchy_to_ssm(appconfig).with_prefixed_names("SSMParam")
convert_hierarchy_to_ssm(clean_config).with_prefixed_names("SSMParam")
)


def _create_embedded_invalidations(appconfig: dict, stack: Stack):
"""Invalidate the cache in applications that use some of these parameters
(by restarting the application), as specified by configuration embedded
inline in the input file.
"""
invalidatable_services = appconfig.get(".ssmash-config", {}).get("invalidations")
if not invalidatable_services:
return

clean_config = dict(appconfig)
clean_config.pop(".ssmash-config", None)
invalidated_resources = _get_invalidated_resources(clean_config)

for appname, appresources in invalidated_resources.items():
invalidator = invalidatable_services.get(appname)
if not invalidator:
# TODO this error message is a bit fragile
raise ValueError(
f"Parameter {appresources[0].Properties.Name} invalidates service {appname}, but that service is not defined."
)

stack.merge_stack(
invalidator.create_resources(appresources).with_prefixed_names(
"Invalidate" + clean_logical_name(appname)
)
)


def _get_invalidated_resources(appconfig: dict) -> Dict[str, List[Resource]]:
"""Lookup which applications are associated with resources.
Returns:
A dictionary of {application_name: [cfn_resource]}
"""
result = dict()

for key, value in appconfig.items():
if isinstance(key, InvalidatingConfigKey):
for appname in key.invalidated_applications:
result.setdefault(appname, []).extend(key.dependent_resources)
if isinstance(value, dict):
for appname, appresources in _get_invalidated_resources(value).items():
result.setdefault(appname, []).extend(appresources)

return result


def _initialise_stack(description: str) -> Stack:
"""Create a basic Flying Circus stack, customised for ssmash"""
stack = Stack(Description=description)
Expand All @@ -252,7 +308,7 @@ def _initialise_stack(description: str) -> Stack:

def _load_appconfig_from_yaml(input) -> dict:
"""Load a YAML description of the application configuration"""
appconfig = yaml.safe_load(input)
appconfig = yaml.load(input, SsmashYamlLoader)

# Note that PyYAML returns None for an empty file, rather than an empty
# dictionary
Expand Down
108 changes: 108 additions & 0 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import contextmanager
from datetime import datetime
from datetime import timezone
from textwrap import dedent
from unittest.mock import ANY
from unittest.mock import patch

Expand All @@ -12,6 +13,7 @@
import yaml
from click.testing import CliRunner
from flyingcircus.intrinsic_function import ImportValue
from flyingcircus.service.ssm import SSMParameter
from freezegun import freeze_time

from ssmash import cli
Expand Down Expand Up @@ -397,3 +399,109 @@ def test_should_error_if_both_name_and_import_specified(self, param_name):
# Verify
assert result.exit_code != 0
invalidation_mock.assert_not_called()


class TestEmbeddedInvalidation:
def run_script_with_embedded_invalidation(
self,
cluster="fake-cluster-name",
service="fake-service-name",
role="fake-role-name",
):
"""Execute script with simple input, and ECS service invalidation."""
param_input = dedent(
f"""---
top:
first:
a: 1
b: 2
? !item {{invalidates: [servicea], key: second}}
:
a: 1
b: 2
third:
a: 1
? !item {{invalidates: [servicea], key: b}}
: 2
.ssmash-config:
invalidations:
servicea: !ecs-invalidation
cluster_name: {cluster}
service_name: {service}
role_name: {role}
"""
)

runner = CliRunner()
result = runner.invoke(
cli.run_ssmash, input=param_input, catch_exceptions=False
)
return result

def test_should_create_resources(self):
# Exercise
with Patchers.write_cfn_template() as write_mock:
result = self.run_script_with_embedded_invalidation()

# Verify
assert result.exit_code == 0
assert not result.stderr_bytes

write_mock.assert_called_once()
actual_stack = write_mock.call_args[0][2]

actual_parameter_names = [
k for k, v in actual_stack.Resources.items() if isinstance(v, SSMParameter)
]
assert sorted(actual_parameter_names) == [
"SSMParamTopFirstA",
"SSMParamTopFirstB",
"SSMParamTopSecondA",
"SSMParamTopSecondB",
"SSMParamTopThirdA",
"SSMParamTopThirdB",
]

assert (
actual_stack.Resources["SSMParamTopSecondA"].Properties.Name
== "/top/second/a"
), "Should use plain key when node is invalidated"
assert (
actual_stack.Resources["SSMParamTopSecondA"].Properties.Value == "1"
), "Should use plain value when node is invalidated"

assert (
actual_stack.Resources["SSMParamTopThirdB"].Properties.Name
== "/top/third/b"
), "Should use plain key when leaf value is invalidated"
assert (
actual_stack.Resources["SSMParamTopThirdB"].Properties.Value == "2"
), "Should use plain value when leaf value is invalidated"

def test_should_call_invalidation_helper_with_dependent_parameters(self):
# Setup
cluster = "arn:cluster"
service = "arn:service"
role = "arn:role"

# Exercise
with Patchers.create_ecs_service_invalidation_stack() as invalidation_mock:
result = self.run_script_with_embedded_invalidation(cluster, service, role)

# Verify
assert result.exit_code == 0
assert not result.stderr_bytes

invalidation_mock.assert_called_once_with(
cluster=cluster, service=service, dependencies=ANY, restart_role=role
)

dependency_names = sorted(
param.Properties.Name
for param in (invalidation_mock.call_args[1]["dependencies"])
)
assert dependency_names == ["/top/second/a", "/top/second/b", "/top/third/b"]

assert cluster in result.stdout
assert service in result.stdout
assert role in result.stdout

0 comments on commit cca3933

Please sign in to comment.