Merge pull request #43302 from lyft/upstream-boto_cloudfront

Upstream boto_cloudfront execution and state modules
This commit is contained in:
Mike Place 2017-09-12 10:10:20 -06:00 committed by GitHub
commit 1a81663e46
7 changed files with 928 additions and 0 deletions

View file

@ -44,6 +44,7 @@ execution modules
boto_apigateway
boto_asg
boto_cfn
boto_cloudfront
boto_cloudtrail
boto_cloudwatch
boto_cloudwatch_event

View file

@ -0,0 +1,6 @@
============================
salt.modules.boto_cloudfront
============================
.. automodule:: salt.modules.boto_cloudfront
:members:

View file

@ -31,6 +31,7 @@ state modules
boto_apigateway
boto_asg
boto_cfn
boto_cloudfront
boto_cloudtrail
boto_cloudwatch_alarm
boto_cloudwatch_event

View file

@ -0,0 +1,6 @@
===========================
salt.states.boto_cloudfront
===========================
.. automodule:: salt.states.boto_cloudfront
:members:

View file

@ -0,0 +1,462 @@
# -*- coding: utf-8 -*-
'''
Connection module for Amazon CloudFront
.. versionadded:: Oxygen
:depends: boto3
:configuration: This module accepts explicit AWS credentials but can also
utilize IAM roles assigned to the instance through Instance Profiles or
it can read them from the ~/.aws/credentials file or from these
environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
Dynamic credentials are then automatically obtained from AWS API and no
further configuration is necessary. More information available at:
.. code-block:: text
http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/
iam-roles-for-amazon-ec2.html
http://boto3.readthedocs.io/en/latest/guide/
configuration.html#guide-configuration
If IAM roles are not used you need to specify them either in a pillar or
in the minion's config file:
.. code-block:: yaml
cloudfront.keyid: GKTADJGHEIQSXMKKRBJ08H
cloudfront.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
A region may also be specified in the configuration:
.. code-block:: yaml
cloudfront.region: us-east-1
If a region is not specified, the default is us-east-1.
It's also possible to specify key, keyid and region via a profile, either
as a passed in dict, or as a string to pull from pillars or minion config:
.. code-block:: yaml
myprofile:
keyid: GKTADJGHEIQSXMKKRBJ08H
key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
region: us-east-1
'''
# keep lint from choking on _get_conn and _cache_id
# pylint: disable=E0602
# Import Python libs
from __future__ import absolute_import
import logging
# Import Salt libs
import salt.ext.six as six
from salt.utils.odict import OrderedDict
import yaml
# Import third party libs
try:
# pylint: disable=unused-import
import boto3
import botocore
# pylint: enable=unused-import
logging.getLogger('boto3').setLevel(logging.CRITICAL)
HAS_BOTO = True
except ImportError:
HAS_BOTO = False
log = logging.getLogger(__name__)
def __virtual__():
'''
Only load if boto3 libraries exist.
'''
if not HAS_BOTO:
msg = 'The boto_cloudfront module could not be loaded: {}.'
return (False, msg.format('boto3 libraries not found'))
__utils__['boto3.assign_funcs'](__name__, 'cloudfront')
return True
def _list_distributions(
conn,
name=None,
region=None,
key=None,
keyid=None,
profile=None,
):
'''
Private function that returns an iterator over all CloudFront distributions.
The caller is responsible for all boto-related error handling.
name
(Optional) Only yield the distribution with the given name
'''
for dl_ in conn.get_paginator('list_distributions').paginate():
distribution_list = dl_['DistributionList']
if 'Items' not in distribution_list:
# If there are no items, AWS omits the `Items` key for some reason
continue
for partial_dist in distribution_list['Items']:
tags = conn.list_tags_for_resource(Resource=partial_dist['ARN'])
tags = dict(
(kv['Key'], kv['Value']) for kv in tags['Tags']['Items']
)
id_ = partial_dist['Id']
if 'Name' not in tags:
log.warning(
'CloudFront distribution {0} has no Name tag.'.format(id_),
)
continue
distribution_name = tags.pop('Name', None)
if name is not None and distribution_name != name:
continue
# NOTE: list_distributions() returns a DistributionList,
# which nominally contains a list of Distribution objects.
# However, they are mangled in that they are missing values
# (`Logging`, `ActiveTrustedSigners`, and `ETag` keys)
# and moreover flatten the normally nested DistributionConfig
# attributes to the top level.
# Hence, we must call get_distribution() to get the full object,
# and we cache these objects to help lessen API calls.
distribution = _cache_id(
'cloudfront',
sub_resource=distribution_name,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if distribution:
yield (distribution_name, distribution)
continue
dist_with_etag = conn.get_distribution(Id=id_)
distribution = {
'distribution': dist_with_etag['Distribution'],
'etag': dist_with_etag['ETag'],
'tags': tags,
}
_cache_id(
'cloudfront',
sub_resource=distribution_name,
resource_id=distribution,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
yield (distribution_name, distribution)
def get_distribution(name, region=None, key=None, keyid=None, profile=None):
'''
Get information about a CloudFront distribution (configuration, tags) with a given name.
name
Name of the CloudFront distribution
region
Region to connect to
key
Secret key to use
keyid
Access key to use
profile
A dict with region, key, and keyid,
or a pillar key (string) that contains such a dict.
CLI Example:
.. code-block:: bash
salt myminion boto_cloudfront.get_distribution name=mydistribution profile=awsprofile
'''
distribution = _cache_id(
'cloudfront',
sub_resource=name,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if distribution:
return {'result': distribution}
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
try:
for _, dist in _list_distributions(
conn,
name=name,
region=region,
key=key,
keyid=keyid,
profile=profile,
):
# _list_distributions should only return the one distribution
# that we want (with the given name).
# In case of multiple distributions with the same name tag,
# our use of caching means list_distributions will just
# return the first one over and over again,
# so only the first result is useful.
if distribution is not None:
msg = 'More than one distribution found with name {0}'
return {'error': msg.format(name)}
distribution = dist
except botocore.exceptions.ClientError as err:
return {'error': __utils__['boto3.get_error'](err)}
if not distribution:
return {'result': None}
_cache_id(
'cloudfront',
sub_resource=name,
resource_id=distribution,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
return {'result': distribution}
def export_distributions(region=None, key=None, keyid=None, profile=None):
'''
Get details of all CloudFront distributions.
Produces results that can be used to create an SLS file.
CLI Example:
.. code-block:: bash
salt-call boto_cloudfront.export_distributions --out=txt |\
sed "s/local: //" > cloudfront_distributions.sls
'''
results = OrderedDict()
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
try:
for name, distribution in _list_distributions(
conn,
region=region,
key=key,
keyid=keyid,
profile=profile,
):
config = distribution['distribution']['DistributionConfig']
tags = distribution['tags']
distribution_sls_data = [
{'name': name},
{'config': config},
{'tags': tags},
]
results['Manage CloudFront distribution {0}'.format(name)] = {
'boto_cloudfront.present': distribution_sls_data,
}
except botocore.exceptions.ClientError as err:
# Raise an exception, as this is meant to be user-invoked at the CLI
# as opposed to being called from execution or state modules
raise err
dumper = __utils__['yamldumper.get_dumper']('IndentedSafeOrderedDumper')
return yaml.dump(
results,
default_flow_style=False,
Dumper=dumper,
)
def create_distribution(
name,
config,
tags=None,
region=None,
key=None,
keyid=None,
profile=None,
):
'''
Create a CloudFront distribution with the given name, config, and (optionally) tags.
name
Name for the CloudFront distribution
config
Configuration for the distribution
tags
Tags to associate with the distribution
region
Region to connect to
key
Secret key to use
keyid
Access key to use
profile
A dict with region, key, and keyid,
or a pillar key (string) that contains such a dict.
CLI Example:
.. code-block:: bash
salt myminion boto_cloudfront.create_distribution name=mydistribution profile=awsprofile \
config='{"Comment":"partial configuration","Enabled":true}'
'''
if tags is None:
tags = {}
if 'Name' in tags:
# Be lenient and silently accept if names match, else error
if tags['Name'] != name:
return {'error': 'Must not pass `Name` in `tags` but as `name`'}
tags['Name'] = name
tags = {
'Items': [{'Key': k, 'Value': v} for k, v in six.iteritems(tags)]
}
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
try:
conn.create_distribution_with_tags(
DistributionConfigWithTags={
'DistributionConfig': config,
'Tags': tags,
},
)
_cache_id(
'cloudfront',
sub_resource=name,
invalidate=True,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
except botocore.exceptions.ClientError as err:
return {'error': __utils__['boto3.get_error'](err)}
return {'result': True}
def update_distribution(
name,
config,
tags=None,
region=None,
key=None,
keyid=None,
profile=None,
):
'''
Update the config (and optionally tags) for the CloudFront distribution with the given name.
name
Name of the CloudFront distribution
config
Configuration for the distribution
tags
Tags to associate with the distribution
region
Region to connect to
key
Secret key to use
keyid
Access key to use
profile
A dict with region, key, and keyid,
or a pillar key (string) that contains such a dict.
CLI Example:
.. code-block:: bash
salt myminion boto_cloudfront.update_distribution name=mydistribution profile=awsprofile \
config='{"Comment":"partial configuration","Enabled":true}'
'''
distribution_ret = get_distribution(
name,
region=region,
key=key,
keyid=keyid,
profile=profile
)
if 'error' in distribution_result:
return distribution_result
dist_with_tags = distribution_result['result']
current_distribution = dist_with_tags['distribution']
current_config = current_distribution['DistributionConfig']
current_tags = dist_with_tags['tags']
etag = dist_with_tags['etag']
config_diff = __utils__['dictdiffer.deep_diff'](current_config, config)
if tags:
tags_diff = __utils__['dictdiffer.deep_diff'](current_tags, tags)
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
try:
if 'old' in config_diff or 'new' in config_diff:
conn.update_distribution(
DistributionConfig=config,
Id=current_distribution['Id'],
IfMatch=etag,
)
if tags:
arn = current_distribution['ARN']
if 'new' in tags_diff:
tags_to_add = {
'Items': [
{'Key': k, 'Value': v}
for k, v in six.iteritems(tags_diff['new'])
],
}
conn.tag_resource(
Resource=arn,
Tags=tags_to_add,
)
if 'old' in tags_diff:
tags_to_remove = {
'Items': list(tags_diff['old'].keys()),
}
conn.untag_resource(
Resource=arn,
TagKeys=tags_to_remove,
)
except botocore.exceptions.ClientError as err:
return {'error': __utils__['boto3.get_error'](err)}
finally:
_cache_id(
'cloudfront',
sub_resource=name,
invalidate=True,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
return {'result': True}

View file

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
'''
Manage CloudFront distributions
.. versionadded:: Oxygen
Create, update and destroy CloudFront distributions.
This module accepts explicit AWS credentials but can also utilize
IAM roles assigned to the instance through Instance Profiles.
Dynamic credentials are then automatically obtained from AWS API
and no further configuration is necessary.
More information available `here
<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html>`_.
If IAM roles are not used you need to specify them,
either in a pillar file or in the minion's config file:
.. code-block:: yaml
cloudfront.keyid: GKTADJGHEIQSXMKKRBJ08H
cloudfront.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
It's also possible to specify ``key``, ``keyid``, and ``region`` via a profile,
either passed in as a dict, or a string to pull from pillars or minion config:
.. code-block:: yaml
myprofile:
keyid: GKTADJGHEIQSXMKKRBJ08H
key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
region: us-east-1
.. code-block:: yaml
aws:
region:
us-east-1:
profile:
keyid: GKTADJGHEIQSXMKKRBJ08H
key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
region: us-east-1
:depends: boto3
'''
# Import Python Libs
from __future__ import absolute_import
import difflib
import logging
import yaml
log = logging.getLogger(__name__)
def __virtual__():
'''
Only load if boto is available.
'''
if 'boto_cloudfront.get_distribution' not in __salt__:
msg = 'The boto_cloudfront state module could not be loaded: {}.'
return (False, msg.format('boto_cloudfront exec module unavailable.'))
return 'boto_cloudfront'
def present(
name,
config,
tags,
region=None,
key=None,
keyid=None,
profile=None,
):
'''
Ensure the CloudFront distribution is present.
name (string)
Name of the CloudFront distribution
config (dict)
Configuration for the distribution
tags (dict)
Tags to associate with the distribution
region (string)
Region to connect to
key (string)
Secret key to use
keyid (string)
Access key to use
profile (dict or string)
A dict with region, key, and keyid,
or a pillar key (string) that contains such a dict.
Example:
.. code-block:: yaml
Manage my_distribution CloudFront distribution:
boto_cloudfront.present:
- name: my_distribution
- config:
Comment: 'partial config shown, most parameters elided'
Enabled: True
- tags:
testing_key: testing_value
'''
ret = {
'name': name,
'comment': '',
'changes': {},
}
res = __salt__['boto_cloudfront.get_distribution'](
name,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if 'error' in res:
ret['result'] = False
ret['comment'] = 'Error checking distribution {0}: {1}'.format(
name,
res['error'],
)
return ret
old = res['result']
if old is None:
if __opts__['test']:
ret['result'] = None
ret['comment'] = 'Distribution {0} set for creation.'.format(name)
ret['pchanges'] = {'old': None, 'new': name}
return ret
res = __salt__['boto_cloudfront.create_distribution'](
name,
config,
tags,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if 'error' in res:
ret['result'] = False
ret['comment'] = 'Error creating distribution {0}: {1}'.format(
name,
res['error'],
)
return ret
ret['result'] = True
ret['comment'] = 'Created distribution {0}.'.format(name)
ret['changes'] = {'old': None, 'new': name}
return ret
else:
full_config_old = {
'config': old['distribution']['DistributionConfig'],
'tags': old['tags'],
}
full_config_new = {
'config': config,
'tags': tags,
}
diffed_config = __utils__['dictdiffer.deep_diff'](
full_config_old,
full_config_new,
)
def _yaml_safe_dump(attrs):
'''Safely dump YAML using a readable flow style'''
dumper_name = 'IndentedSafeOrderedDumper'
dumper = __utils__['yamldumper.get_dumper'](dumper_name)
return yaml.dump(
attrs,
default_flow_style=False,
Dumper=dumper,
)
changes_diff = ''.join(difflib.unified_diff(
_yaml_safe_dump(full_config_old).splitlines(True),
_yaml_safe_dump(full_config_new).splitlines(True),
))
any_changes = bool('old' in diffed_config or 'new' in diffed_config)
if not any_changes:
ret['result'] = True
ret['comment'] = 'Distribution {0} has correct config.'.format(
name,
)
return ret
if __opts__['test']:
ret['result'] = None
ret['comment'] = '\n'.join([
'Distribution {0} set for new config:'.format(name),
changes_diff,
])
ret['pchanges'] = {'diff': changes_diff}
return ret
res = __salt__['boto_cloudfront.update_distribution'](
name,
config,
tags,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if 'error' in res:
ret['result'] = False
ret['comment'] = 'Error updating distribution {0}: {1}'.format(
name,
res['error'],
)
return ret
ret['result'] = True
ret['comment'] = 'Updated distribution {0}.'.format(name)
ret['changes'] = {'diff': changes_diff}
return ret

View file

@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
'''
Unit tests for the boto_cloudfront state module.
'''
# Import Python libs
from __future__ import absolute_import
import copy
import textwrap
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.unit import skipIf, TestCase
from tests.support.mock import NO_MOCK, NO_MOCK_REASON, MagicMock, patch
# Import Salt Libs
import salt.config
import salt.loader
import salt.states.boto_cloudfront as boto_cloudfront
@skipIf(NO_MOCK, NO_MOCK_REASON)
class BotoCloudfrontTestCase(TestCase, LoaderModuleMockMixin):
'''
Test cases for salt.states.boto_cloudfront
'''
def setup_loader_modules(self):
utils = salt.loader.utils(
self.opts,
whitelist=['boto3', 'dictdiffer', 'yamldumper'],
context={},
)
return {
boto_cloudfront: {
'__utils__': utils,
}
}
@classmethod
def setUpClass(cls):
cls.opts = salt.config.DEFAULT_MINION_OPTS
cls.name = 'my_distribution'
cls.base_ret = {'name': cls.name, 'changes': {}}
# Most attributes elided since there are so many required ones
cls.config = {'Enabled': True, 'HttpVersion': 'http2'}
cls.tags = {'test_tag1': 'value1'}
@classmethod
def tearDownClass(cls):
del cls.opts
del cls.name
del cls.base_ret
del cls.config
del cls.tags
def base_ret_with(self, extra_ret):
new_ret = copy.deepcopy(self.base_ret)
new_ret.update(extra_ret)
return new_ret
def test_present_distribution_retrieval_error(self):
'''
Test for boto_cloudfront.present when we cannot get the distribution.
'''
mock_get = MagicMock(return_value={'error': 'get_distribution error'})
with patch.multiple(boto_cloudfront,
__salt__={'boto_cloudfront.get_distribution': mock_get},
__opts__={'test': False},
):
comment = 'Error checking distribution {0}: get_distribution error'
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': False,
'comment': comment.format(self.name),
}),
)
def test_present_from_scratch(self):
mock_get = MagicMock(return_value={'result': None})
with patch.multiple(boto_cloudfront,
__salt__={'boto_cloudfront.get_distribution': mock_get},
__opts__={'test': True},
):
comment = 'Distribution {0} set for creation.'.format(self.name)
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': None,
'comment': comment,
'pchanges': {'old': None, 'new': self.name},
}),
)
mock_create_failure = MagicMock(return_value={'error': 'create error'})
with patch.multiple(boto_cloudfront,
__salt__={
'boto_cloudfront.get_distribution': mock_get,
'boto_cloudfront.create_distribution': mock_create_failure,
},
__opts__={'test': False},
):
comment = 'Error creating distribution {0}: create error'
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': False,
'comment': comment.format(self.name),
}),
)
mock_create_success = MagicMock(return_value={'result': True})
with patch.multiple(boto_cloudfront,
__salt__={
'boto_cloudfront.get_distribution': mock_get,
'boto_cloudfront.create_distribution': mock_create_success,
},
__opts__={'test': False},
):
comment = 'Created distribution {0}.'
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': True,
'comment': comment.format(self.name),
'changes': {'old': None, 'new': self.name},
}),
)
def test_present_correct_state(self):
mock_get = MagicMock(return_value={'result': {
'distribution': {'DistributionConfig': self.config},
'tags': self.tags,
'etag': 'test etag',
}})
with patch.multiple(boto_cloudfront,
__salt__={'boto_cloudfront.get_distribution': mock_get},
__opts__={'test': False},
):
comment = 'Distribution {0} has correct config.'
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': True,
'comment': comment.format(self.name),
}),
)
def test_present_update_config_and_tags(self):
mock_get = MagicMock(return_value={'result': {
'distribution': {'DistributionConfig': {
'Enabled': False,
'Comment': 'to be removed',
}},
'tags': {'bad existing tag': 'also to be removed'},
'etag': 'test etag',
}})
diff = textwrap.dedent('''\
---
+++
@@ -1,5 +1,5 @@
config:
- Comment: to be removed
- Enabled: false
+ Enabled: true
+ HttpVersion: http2
tags:
- bad existing tag: also to be removed
+ test_tag1: value1
''')
with patch.multiple(boto_cloudfront,
__salt__={'boto_cloudfront.get_distribution': mock_get},
__opts__={'test': True},
):
header = 'Distribution {0} set for new config:'.format(self.name)
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': None,
'comment': '\n'.join([header, diff]),
'pchanges': {'diff': diff},
}),
)
mock_update_failure = MagicMock(return_value={'error': 'update error'})
with patch.multiple(boto_cloudfront,
__salt__={
'boto_cloudfront.get_distribution': mock_get,
'boto_cloudfront.update_distribution': mock_update_failure,
},
__opts__={'test': False},
):
comment = 'Error updating distribution {0}: update error'
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': False,
'comment': comment.format(self.name),
}),
)
mock_update_success = MagicMock(return_value={'result': True})
with patch.multiple(boto_cloudfront,
__salt__={
'boto_cloudfront.get_distribution': mock_get,
'boto_cloudfront.update_distribution': mock_update_success,
},
__opts__={'test': False},
):
self.assertDictEqual(
boto_cloudfront.present(self.name, self.config, self.tags),
self.base_ret_with({
'result': True,
'comment': 'Updated distribution {0}.'.format(self.name),
'changes': {'diff': diff},
}),
)