Skip to content

Commit

Permalink
[Resolves #620] move sceptre-ssm-resolver (#2)
Browse files Browse the repository at this point in the history
* Move the sceptre-ssm-resolver from https://github.com/zaro0508/sceptre-ssm-resolver
to the official Sceptre github Org.  
* Slightly refactored to include unit tests.
* add boto3 to requirements
* replaced sceptre-core with sceptre in requirements
* work around for ImportError:
CircleCi buld reports ImportError.  Use work around suggested at:
https://stackoverflow.com/questions/10253826/path-issue-with-pytest-importerror-no-module-named-yadayadayada
  • Loading branch information
zaro0508 committed Sep 30, 2019
1 parent 315bcf8 commit 3bd40d5
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 48 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,4 @@ Session.vim
tags
# Persistent undo
[._]*.un~
temp/
10 changes: 4 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ Categories: Added, Removed, Changed, Fixed, Nonfunctional, Deprecated

<!--- All unreleased items go here -->

<!--- Example CHANGELOG entry
## 0.1.0 (2019.07.02)
## 1.1.0 (2019.09.15)

### Added

- Initial resolver code
-->
- Initial resolver code from https://github.com/zaro0508/sceptre-ssm-resolver
- Added unit tests
- Tested with Sceptre v2.2.1
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ coverage: coverage-all
coverage report --show-missing

test:
pytest --junitxml=test-reports/junit.xml

python -m pytest --junitxml=test-reports/junit.xml
lint:
flake8 .

Expand Down
45 changes: 35 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
# README
---
layout: docs
title: Resolvers
---

Add your resolver readme here. Remember to include the following:
# Overview

- Tell people how to install it (e.g. pip install ...).
- Be clear about the purpose of the resolver, its capabilities and limitations.
- Tell people how to use it.
- Give examples of the resolver in use.
The purpose of this resolver is to retrieve values from the AWS SSM.

Read our wiki to learn how to use this repo:
https://github.com/Sceptre/project/wiki/Sceptre-Resolver-Template
## Available Resolvers

If you have any questions or encounter an issue
[please open an issue](https://github.com/Sceptre/project/issues/new)
### ssm

Fetches the value stored in AWS SSM Parameter Store.

Syntax:

```yaml
parameter|sceptre_user_data:
<name>: !ssm /prefix/param
```

#### Example:

Add a secure string to the SSM parameter store
```
aws ssm put-parameter --name /dev/DbPassword --value "mysecret" \
--key-id alias/dev/kmskey --type "SecureString"
```

Setup sceptre template to retrieve and decrypt from parameter store
```
parameters:
database_password: !ssm /dev/DbPassword
```

Run sceptre with a user or role that has access to the secret.
Sceptre will retrieve "mysecret" from the parameter store and passes
it to the cloudformation _database_password_ paramter.
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
bumpversion==0.5.3
boto3>=1.3.0,<2
coverage==4.4.2
flake8==3.5.0
flake8-per-file-ignores==0.4
Expand All @@ -8,7 +9,7 @@ pytest-runner>=3.0.0,<3.1.0
pytest>=3.2.0,<3.3.0
readme-renderer>=24.0
setuptools>=40.6.2
git+git://github.com/sceptre/sceptre-core.git@master#egg=sceptre-core
git+git://github.com/sceptre/sceptre.git@master#egg=sceptre
tox>=2.9.1,<3.0.0
twine>=1.12.1
wheel==0.32.3
8 changes: 8 additions & 0 deletions resolver/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-


class ParameterNotFoundError(Exception):
"""
Error raised when the SSM parameter does not exist
"""
pass
14 changes: 0 additions & 14 deletions resolver/resolver.py

This file was deleted.

103 changes: 103 additions & 0 deletions resolver/ssm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-

import abc
import six
import logging

from botocore.exceptions import ClientError

from sceptre.resolvers import Resolver
from resolver.exceptions import ParameterNotFoundError

TEMPLATE_EXTENSION = ".yaml"


@six.add_metaclass(abc.ABCMeta)
class SsmBase(Resolver):
"""
A abstract base class which provides methods for getting SSM parameters.
"""

def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(__name__)
super(SsmBase, self).__init__(*args, **kwargs)

def _get_parameter_value(self, param, profile=None, region=None):
"""
Attempts to get the SSM parameter named by ``param``
:param param: The name of the SSM parameter in which to return.
:type param: str
:returns: SSM parameter value.
:rtype: str
:raises: KeyError
"""
response = self._request_parameter(param, profile, region)

try:
return response['Parameter']['Value']
except KeyError:
self.logger.error("%s - Invalid response looking for: %s",
self.stack.name, param)
raise

def _request_parameter(self, param, profile=None, region=None):
"""
Communicates with AWS CloudFormation to fetch SSM parameters.
:returns: The decoded value of the parameter
:rtype: dict
:raises: resolver.exceptions.ParameterNotFoundError
"""
connection_manager = self.stack.connection_manager

try:
response = connection_manager.call(
service="ssm",
command="get_parameter",
kwargs={"Name": param,
"WithDecryption": True},
profile=profile,
region=region,
)
except ClientError as e:
if "ParameterNotFound" in e.response["Error"]["Code"]:
self.logger.error("%s - ParameterNotFound: %s",
self.stack.name, param)
raise ParameterNotFoundError(e.response["Error"]["Message"])
else:
raise e
else:
return response


class SSM(SsmBase):
"""
Resolver for retrieving the value of an SSM parameter.
:param argument: The parameter name to get.
:type argument: str
"""

def __init__(self, *args, **kwargs):
super(SSM, self).__init__(*args, **kwargs)

def resolve(self):
"""
Retrieves the value of SSM parameter
:returns: The decoded value of the SSM parameter
:rtype: str
"""
self.logger.debug(
"Resolving SSM parameter: {0}".format(self.argument)
)

value = None
profile = self.stack.profile
region = self.stack.region
if self.argument:
param = self.argument
value = self._get_parameter_value(param, profile, region)

return value
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.1.2
current_version = 1.1.0
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
commit = True
tag = True
Expand Down
14 changes: 7 additions & 7 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
from setuptools import setup, find_packages

__version__ = "0.0.1"
__version__ = "1.1.0"

# More information on setting values:
# https://github.com/Sceptre/project/wiki/sceptre-resolver-template

# lowercase, use `-` as separator.
RESOLVER_NAME = 'sceptre-resolver-template'
RESOLVER_NAME = 'sceptre-ssm-resolver'
# the resolver call in sceptre e.g. !command_name.
RESOLVER_COMMAND_NAME = 'custom_resolver'
RESOLVER_COMMAND_NAME = 'ssm'
# do not change. Rename resolver/resolver.py to resolver/{RESOLVER_COMMAND_NAME}.py
RESOLVER_MODULE_NAME = 'resolver.{}'.format(RESOLVER_COMMAND_NAME)
# CamelCase name of resolver class in resolver.resolver.
RESOLVER_CLASS = 'CustomResolver'
RESOLVER_CLASS = 'SSM'
# One line summary description
RESOLVER_DESCRIPTION = ''
RESOLVER_DESCRIPTION = 'A Sceptre resolver to retrieve data from the AWS secure store'
# if multiple use a single string with comma separated names.
RESOLVER_AUTHOR = 'Sceptre'
RESOLVER_AUTHOR = 'zaro0508'
# if multiple use single string with commas.
RESOLVER_AUTHOR_EMAIL = 'sceptre@cloudreach.com'
RESOLVER_AUTHOR_EMAIL = 'zaro0508@gmail.com'
RESOLVER_URL = 'https://github.com/sceptre/{}'.format(RESOLVER_NAME)

with open("README.md") as readme_file:
Expand Down
7 changes: 0 additions & 7 deletions tests/test_resolver.py

This file was deleted.

120 changes: 120 additions & 0 deletions tests/test_ssm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-

import pytest
from mock import MagicMock, patch, sentinel

from botocore.exceptions import ClientError

from sceptre.connection_manager import ConnectionManager
from sceptre.stack import Stack

from resolver.ssm import SSM, SsmBase
from resolver.exceptions import ParameterNotFoundError


class TestSsmResolver(object):

@patch(
"resolver.ssm.SSM._get_parameter_value"
)
def test_resolve(self, mock_get_parameter_value):
stack = MagicMock(spec=Stack)
stack.profile = "test_profile"
stack.region = "test_region"
stack.dependencies = []
stack._connection_manager = MagicMock(spec=ConnectionManager)
stack_ssm_resolver = SSM(
"/dev/DbPassword", stack
)
mock_get_parameter_value.return_value = "parameter_value"
stack_ssm_resolver.resolve()
mock_get_parameter_value.assert_called_once_with(
"/dev/DbPassword", "test_profile", "test_region"
)
assert stack.dependencies == []


class MockSsmBase(SsmBase):
"""
MockBaseResolver inherits from the abstract base class
SsmBase, and implements the abstract methods. It is used
to allow testing on SsmBase, which is not otherwise
instantiable.
"""

def __init__(self, *args, **kwargs):
super(MockSsmBase, self).__init__(*args, **kwargs)

def resolve(self):
pass


class TestSsmBase(object):

def setup_method(self, test_method):
self.stack = MagicMock(spec=Stack)
self.stack.name = "test_name"
self.stack._connection_manager = MagicMock(
spec=ConnectionManager
)
self.base_ssm = MockSsmBase(
None, self.stack
)

@patch(
"resolver.ssm.SsmBase._request_parameter"
)
def test_get_parameter_value_with_valid_key(self, mock_request_parameter):
mock_request_parameter.return_value = {
"Parameter": {
"Name": "/dev/DbPassword",
"Type": "SecureString",
"Value": "Secret",
"Version": 1,
"LastModifiedDate": 1531863312.945,
"ARN": "arn:aws:ssm:us-east-1:111111111111:parameter/dev/DbPassword"
}
}
response = self.base_ssm._get_parameter_value("/dev/DbPassword")
assert response == "Secret"

@patch(
"resolver.ssm.SsmBase._request_parameter"
)
def test_get_parameter_value_with_invalid_response(self, mock_request_parameter):
mock_request_parameter.return_value = {
"Parameter": {
"Name": "/dev/DbPassword"
}
}

with pytest.raises(KeyError):
self.base_ssm._get_parameter_value(None)

def test_request_parameter_with_unkown_boto_error(self):
self.stack.connection_manager.call.side_effect = ClientError(
{
"Error": {
"Code": "500",
"Message": "Boom!"
}
},
sentinel.operation
)

with pytest.raises(ClientError):
self.base_ssm._request_parameter(None)

def test_request_parameter_with_parameter_not_found(self):
self.stack.connection_manager.call.side_effect = ClientError(
{
"Error": {
"Code": "ParameterNotFound",
"Message": "Boom!"
}
},
sentinel.operation
)

with pytest.raises(ParameterNotFoundError):
self.base_ssm._request_parameter(None)

0 comments on commit 3bd40d5

Please sign in to comment.