1040 lines
35 KiB
Python
1040 lines
35 KiB
Python
#!/usr/bin/python
|
|
# Copyright: Ansible Project
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
__metaclass__ = type
|
|
|
|
|
|
DOCUMENTATION = r'''
|
|
---
|
|
module: dynamodb_table
|
|
version_added: 1.0.0
|
|
short_description: Create, update or delete AWS Dynamo DB tables
|
|
description:
|
|
- Create or delete AWS Dynamo DB tables.
|
|
- Can update the provisioned throughput on existing tables.
|
|
- Returns the status of the specified table.
|
|
author: Alan Loi (@loia)
|
|
options:
|
|
state:
|
|
description:
|
|
- Create or delete the table.
|
|
choices: ['present', 'absent']
|
|
default: 'present'
|
|
type: str
|
|
name:
|
|
description:
|
|
- Name of the table.
|
|
required: true
|
|
type: str
|
|
hash_key_name:
|
|
description:
|
|
- Name of the hash key.
|
|
- Required when I(state=present) and table doesn't exist.
|
|
type: str
|
|
hash_key_type:
|
|
description:
|
|
- Type of the hash key.
|
|
- Defaults to C('STRING') when creating a new table.
|
|
choices: ['STRING', 'NUMBER', 'BINARY']
|
|
type: str
|
|
range_key_name:
|
|
description:
|
|
- Name of the range key.
|
|
type: str
|
|
range_key_type:
|
|
description:
|
|
- Type of the range key.
|
|
- Defaults to C('STRING') when creating a new range key.
|
|
choices: ['STRING', 'NUMBER', 'BINARY']
|
|
type: str
|
|
billing_mode:
|
|
description:
|
|
- Controls whether provisoned pr on-demand tables are created.
|
|
choices: ['PROVISIONED', 'PAY_PER_REQUEST']
|
|
type: str
|
|
read_capacity:
|
|
description:
|
|
- Read throughput capacity (units) to provision.
|
|
- Defaults to C(1) when creating a new table.
|
|
type: int
|
|
write_capacity:
|
|
description:
|
|
- Write throughput capacity (units) to provision.
|
|
- Defaults to C(1) when creating a new table.
|
|
type: int
|
|
indexes:
|
|
description:
|
|
- list of dictionaries describing indexes to add to the table. global indexes can be updated. local indexes don't support updates or have throughput.
|
|
- "required options: ['name', 'type', 'hash_key_name']"
|
|
- "other options: ['hash_key_type', 'range_key_name', 'range_key_type', 'includes', 'read_capacity', 'write_capacity']"
|
|
suboptions:
|
|
name:
|
|
description: The name of the index.
|
|
type: str
|
|
required: true
|
|
type:
|
|
description:
|
|
- The type of index.
|
|
type: str
|
|
required: true
|
|
choices: ['all', 'global_all', 'global_include', 'global_keys_only', 'include', 'keys_only']
|
|
hash_key_name:
|
|
description:
|
|
- The name of the hash-based key.
|
|
- Required if index doesn't already exist.
|
|
- Can not be modified once the index has been created.
|
|
required: false
|
|
type: str
|
|
hash_key_type:
|
|
description:
|
|
- The type of the hash-based key.
|
|
- Defaults to C('STRING') when creating a new index.
|
|
- Can not be modified once the index has been created.
|
|
type: str
|
|
choices: ['STRING', 'NUMBER', 'BINARY']
|
|
range_key_name:
|
|
description:
|
|
- The name of the range-based key.
|
|
- Can not be modified once the index has been created.
|
|
type: str
|
|
range_key_type:
|
|
type: str
|
|
description:
|
|
- The type of the range-based key.
|
|
- Defaults to C('STRING') when creating a new index.
|
|
- Can not be modified once the index has been created.
|
|
choices: ['STRING', 'NUMBER', 'BINARY']
|
|
includes:
|
|
type: list
|
|
description: A list of fields to include when using C(global_include) or C(include) indexes.
|
|
elements: str
|
|
read_capacity:
|
|
description:
|
|
- Read throughput capacity (units) to provision for the index.
|
|
type: int
|
|
write_capacity:
|
|
description:
|
|
- Write throughput capacity (units) to provision for the index.
|
|
type: int
|
|
default: []
|
|
type: list
|
|
elements: dict
|
|
tags:
|
|
description:
|
|
- A hash/dictionary of tags to add to the new instance or for starting/stopping instance by tag.
|
|
- 'For example: C({"key":"value"}) or C({"key":"value","key2":"value2"})'
|
|
type: dict
|
|
purge_tags:
|
|
description:
|
|
- Remove tags not listed in I(tags).
|
|
default: True
|
|
type: bool
|
|
wait_timeout:
|
|
description:
|
|
- How long (in seconds) to wait for creation / update / deletion to complete.
|
|
aliases: ['wait_for_active_timeout']
|
|
default: 300
|
|
type: int
|
|
wait:
|
|
description:
|
|
- When I(wait=True) the module will wait for up to I(wait_timeout) seconds
|
|
for table creation or deletion to complete before returning.
|
|
default: True
|
|
type: bool
|
|
extends_documentation_fragment:
|
|
- amazon.aws.aws
|
|
- amazon.aws.ec2
|
|
|
|
'''
|
|
|
|
EXAMPLES = r'''
|
|
- name: Create dynamo table with hash and range primary key
|
|
community.aws.dynamodb_table:
|
|
name: my-table
|
|
region: us-east-1
|
|
hash_key_name: id
|
|
hash_key_type: STRING
|
|
range_key_name: create_time
|
|
range_key_type: NUMBER
|
|
read_capacity: 2
|
|
write_capacity: 2
|
|
tags:
|
|
tag_name: tag_value
|
|
|
|
- name: Update capacity on existing dynamo table
|
|
community.aws.dynamodb_table:
|
|
name: my-table
|
|
region: us-east-1
|
|
read_capacity: 10
|
|
write_capacity: 10
|
|
|
|
- name: Create pay-per-request table
|
|
community.aws.dynamodb_table:
|
|
name: my-table
|
|
region: us-east-1
|
|
hash_key_name: id
|
|
hash_key_type: STRING
|
|
billing_mode: PAY_PER_REQUEST
|
|
|
|
- name: set index on existing dynamo table
|
|
community.aws.dynamodb_table:
|
|
name: my-table
|
|
region: us-east-1
|
|
indexes:
|
|
- name: NamedIndex
|
|
type: global_include
|
|
hash_key_name: id
|
|
range_key_name: create_time
|
|
includes:
|
|
- other_field
|
|
- other_field2
|
|
read_capacity: 10
|
|
write_capacity: 10
|
|
|
|
- name: Delete dynamo table
|
|
community.aws.dynamodb_table:
|
|
name: my-table
|
|
region: us-east-1
|
|
state: absent
|
|
'''
|
|
|
|
RETURN = r'''
|
|
table_status:
|
|
description: The current status of the table.
|
|
returned: success
|
|
type: str
|
|
sample: ACTIVE
|
|
'''
|
|
|
|
try:
|
|
import botocore
|
|
except ImportError:
|
|
pass # Handled by AnsibleAWSModule
|
|
|
|
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict
|
|
|
|
from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
|
|
from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code
|
|
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
|
|
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_tag_list
|
|
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict
|
|
from ansible_collections.amazon.aws.plugins.module_utils.ec2 import compare_aws_tags
|
|
|
|
|
|
DYNAMO_TYPE_DEFAULT = 'STRING'
|
|
INDEX_REQUIRED_OPTIONS = ['name', 'type', 'hash_key_name']
|
|
INDEX_OPTIONS = INDEX_REQUIRED_OPTIONS + ['hash_key_type', 'range_key_name', 'range_key_type', 'includes', 'read_capacity', 'write_capacity']
|
|
INDEX_TYPE_OPTIONS = ['all', 'global_all', 'global_include', 'global_keys_only', 'include', 'keys_only']
|
|
# Map in both directions
|
|
DYNAMO_TYPE_MAP_LONG = {'STRING': 'S', 'NUMBER': 'N', 'BINARY': 'B'}
|
|
DYNAMO_TYPE_MAP_SHORT = dict((v, k) for k, v in DYNAMO_TYPE_MAP_LONG.items())
|
|
KEY_TYPE_CHOICES = list(DYNAMO_TYPE_MAP_LONG.keys())
|
|
|
|
|
|
# If you try to update an index while another index is updating, it throws
|
|
# LimitExceededException/ResourceInUseException exceptions at you. This can be
|
|
# pretty slow, so add plenty of retries...
|
|
@AWSRetry.jittered_backoff(
|
|
retries=45, delay=5, max_delay=30,
|
|
catch_extra_error_codes=['LimitExceededException', 'ResourceInUseException', 'ResourceNotFoundException'],
|
|
)
|
|
def _update_table_with_long_retry(**changes):
|
|
return client.update_table(
|
|
TableName=module.params.get('name'),
|
|
**changes
|
|
)
|
|
|
|
|
|
# ResourceNotFoundException is expected here if the table doesn't exist
|
|
@AWSRetry.jittered_backoff(catch_extra_error_codes=['LimitExceededException', 'ResourceInUseException'])
|
|
def _describe_table(**params):
|
|
return client.describe_table(**params)
|
|
|
|
|
|
def wait_exists():
|
|
table_name = module.params.get('name')
|
|
wait_timeout = module.params.get('wait_timeout')
|
|
|
|
delay = min(wait_timeout, 5)
|
|
max_attempts = wait_timeout // delay
|
|
|
|
try:
|
|
waiter = client.get_waiter('table_exists')
|
|
waiter.wait(
|
|
WaiterConfig={'Delay': delay, 'MaxAttempts': max_attempts},
|
|
TableName=table_name,
|
|
)
|
|
except botocore.exceptions.WaiterError as e:
|
|
module.fail_json_aws(e, msg='Timeout while waiting on table creation')
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
|
|
module.fail_json_aws(e, msg='Failed while waiting on table creation')
|
|
|
|
|
|
def wait_not_exists():
|
|
table_name = module.params.get('name')
|
|
wait_timeout = module.params.get('wait_timeout')
|
|
|
|
delay = min(wait_timeout, 5)
|
|
max_attempts = wait_timeout // delay
|
|
|
|
try:
|
|
waiter = client.get_waiter('table_not_exists')
|
|
waiter.wait(
|
|
WaiterConfig={'Delay': delay, 'MaxAttempts': max_attempts},
|
|
TableName=table_name,
|
|
)
|
|
except botocore.exceptions.WaiterError as e:
|
|
module.fail_json_aws(e, msg='Timeout while waiting on table deletion')
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
|
|
module.fail_json_aws(e, msg='Failed while waiting on table deletion')
|
|
|
|
|
|
def _short_type_to_long(short_key):
|
|
if not short_key:
|
|
return None
|
|
return DYNAMO_TYPE_MAP_SHORT.get(short_key, None)
|
|
|
|
|
|
def _long_type_to_short(long_key):
|
|
if not long_key:
|
|
return None
|
|
return DYNAMO_TYPE_MAP_LONG.get(long_key, None)
|
|
|
|
|
|
def _schema_dict(key_name, key_type):
|
|
return dict(
|
|
AttributeName=key_name,
|
|
KeyType=key_type,
|
|
)
|
|
|
|
|
|
def _merge_index_params(index, current_index):
|
|
idx = dict(current_index)
|
|
idx.update(index)
|
|
return idx
|
|
|
|
|
|
def _decode_primary_index(current_table):
|
|
"""
|
|
Decodes the primary index info from the current table definition
|
|
splitting it up into the keys we use as parameters
|
|
"""
|
|
# The schema/attribute definitions are a list of dicts which need the same
|
|
# treatment as boto3's tag lists
|
|
schema = boto3_tag_list_to_ansible_dict(
|
|
current_table.get('key_schema', []),
|
|
# Map from 'HASH'/'RANGE' to attribute name
|
|
tag_name_key_name='key_type',
|
|
tag_value_key_name='attribute_name',
|
|
)
|
|
attributes = boto3_tag_list_to_ansible_dict(
|
|
current_table.get('attribute_definitions', []),
|
|
# Map from attribute name to 'S'/'N'/'B'.
|
|
tag_name_key_name='attribute_name',
|
|
tag_value_key_name='attribute_type',
|
|
)
|
|
|
|
hash_key_name = schema.get('HASH')
|
|
hash_key_type = _short_type_to_long(attributes.get(hash_key_name, None))
|
|
range_key_name = schema.get('RANGE', None)
|
|
range_key_type = _short_type_to_long(attributes.get(range_key_name, None))
|
|
|
|
return dict(
|
|
hash_key_name=hash_key_name,
|
|
hash_key_type=hash_key_type,
|
|
range_key_name=range_key_name,
|
|
range_key_type=range_key_type,
|
|
)
|
|
|
|
|
|
def _decode_index(index_data, attributes, type_prefix=''):
|
|
try:
|
|
index_map = dict(
|
|
name=index_data['index_name'],
|
|
)
|
|
|
|
index_data = dict(index_data)
|
|
index_data['attribute_definitions'] = attributes
|
|
|
|
index_map.update(_decode_primary_index(index_data))
|
|
|
|
throughput = index_data.get('provisioned_throughput', {})
|
|
index_map['provisioned_throughput'] = throughput
|
|
if throughput:
|
|
index_map['read_capacity'] = throughput.get('read_capacity_units')
|
|
index_map['write_capacity'] = throughput.get('write_capacity_units')
|
|
|
|
projection = index_data.get('projection', {})
|
|
if projection:
|
|
index_map['type'] = type_prefix + projection.get('projection_type')
|
|
index_map['includes'] = projection.get('non_key_attributes', [])
|
|
|
|
return index_map
|
|
except Exception as e:
|
|
module.fail_json_aws(e, msg='Decode failure', index_data=index_data)
|
|
|
|
|
|
def compatability_results(current_table):
|
|
if not current_table:
|
|
return dict()
|
|
|
|
billing_mode = current_table.get('billing_mode')
|
|
|
|
primary_indexes = _decode_primary_index(current_table)
|
|
|
|
hash_key_name = primary_indexes.get('hash_key_name')
|
|
hash_key_type = primary_indexes.get('hash_key_type')
|
|
range_key_name = primary_indexes.get('range_key_name')
|
|
range_key_type = primary_indexes.get('range_key_type')
|
|
|
|
indexes = list()
|
|
global_indexes = current_table.get('_global_index_map', {})
|
|
local_indexes = current_table.get('_local_index_map', {})
|
|
for index in global_indexes:
|
|
idx = dict(global_indexes[index])
|
|
idx.pop('provisioned_throughput', None)
|
|
indexes.append(idx)
|
|
for index in local_indexes:
|
|
idx = dict(local_indexes[index])
|
|
idx.pop('provisioned_throughput', None)
|
|
indexes.append(idx)
|
|
|
|
compat_results = dict(
|
|
hash_key_name=hash_key_name,
|
|
hash_key_type=hash_key_type,
|
|
range_key_name=range_key_name,
|
|
range_key_type=range_key_type,
|
|
indexes=indexes,
|
|
billing_mode=billing_mode,
|
|
region=module.region,
|
|
table_name=current_table.get('table_name', None),
|
|
table_status=current_table.get('table_status', None),
|
|
tags=current_table.get('tags', {}),
|
|
)
|
|
|
|
if billing_mode == "PROVISIONED":
|
|
throughput = current_table.get('provisioned_throughput', {})
|
|
compat_results['read_capacity'] = throughput.get('read_capacity_units', None)
|
|
compat_results['write_capacity'] = throughput.get('write_capacity_units', None)
|
|
|
|
return compat_results
|
|
|
|
|
|
def get_dynamodb_table():
|
|
table_name = module.params.get('name')
|
|
try:
|
|
table = _describe_table(TableName=table_name)
|
|
except is_boto3_error_code('ResourceNotFoundException'):
|
|
return None
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
|
|
module.fail_json_aws(e, msg='Failed to describe table')
|
|
|
|
table = table['Table']
|
|
try:
|
|
tags = client.list_tags_of_resource(aws_retry=True, ResourceArn=table['TableArn'])['Tags']
|
|
except is_boto3_error_code('AccessDeniedException'):
|
|
module.warn('Permission denied when listing tags')
|
|
tags = []
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
|
|
module.fail_json_aws(e, msg='Failed to list table tags')
|
|
|
|
tags = boto3_tag_list_to_ansible_dict(tags)
|
|
|
|
table = camel_dict_to_snake_dict(table)
|
|
|
|
# Put some of the values into places people will expect them
|
|
table['arn'] = table['table_arn']
|
|
table['name'] = table['table_name']
|
|
table['status'] = table['table_status']
|
|
table['id'] = table['table_id']
|
|
table['size'] = table['table_size_bytes']
|
|
table['tags'] = tags
|
|
|
|
# billing_mode_summary doesn't always seem to be set but is always set for PAY_PER_REQUEST
|
|
# and when updating the billing_mode
|
|
if 'billing_mode_summary' in table:
|
|
table['billing_mode'] = table['billing_mode_summary']['billing_mode']
|
|
else:
|
|
table['billing_mode'] = "PROVISIONED"
|
|
|
|
# convert indexes into something we can easily search against
|
|
attributes = table['attribute_definitions']
|
|
global_index_map = dict()
|
|
local_index_map = dict()
|
|
for index in table.get('global_secondary_indexes', []):
|
|
idx = _decode_index(index, attributes, type_prefix='global_')
|
|
global_index_map[idx['name']] = idx
|
|
for index in table.get('local_secondary_indexes', []):
|
|
idx = _decode_index(index, attributes)
|
|
local_index_map[idx['name']] = idx
|
|
table['_global_index_map'] = global_index_map
|
|
table['_local_index_map'] = local_index_map
|
|
|
|
return table
|
|
|
|
|
|
def _generate_attribute_map():
|
|
"""
|
|
Builds a map of Key Names to Type
|
|
"""
|
|
attributes = dict()
|
|
|
|
for index in (module.params, *module.params.get('indexes')):
|
|
# run through hash_key_name and range_key_name
|
|
for t in ['hash', 'range']:
|
|
key_name = index.get(t + '_key_name')
|
|
if not key_name:
|
|
continue
|
|
key_type = index.get(t + '_key_type') or DYNAMO_TYPE_DEFAULT
|
|
_type = _long_type_to_short(key_type)
|
|
if key_name in attributes:
|
|
if _type != attributes[key_name]:
|
|
module.fail_json(msg='Conflicting attribute type',
|
|
type_1=_type, type_2=attributes[key_name],
|
|
key_name=key_name)
|
|
else:
|
|
attributes[key_name] = _type
|
|
|
|
return attributes
|
|
|
|
|
|
def _generate_attributes():
|
|
attributes = _generate_attribute_map()
|
|
|
|
# Use ansible_dict_to_boto3_tag_list to generate the list of dicts
|
|
# format we need
|
|
attrs = ansible_dict_to_boto3_tag_list(
|
|
attributes,
|
|
tag_name_key_name='AttributeName',
|
|
tag_value_key_name='AttributeType'
|
|
)
|
|
return list(attrs)
|
|
|
|
|
|
def _generate_throughput(params=None):
|
|
if not params:
|
|
params = module.params
|
|
|
|
read_capacity = params.get('read_capacity') or 1
|
|
write_capacity = params.get('write_capacity') or 1
|
|
throughput = dict(
|
|
ReadCapacityUnits=read_capacity,
|
|
WriteCapacityUnits=write_capacity,
|
|
)
|
|
|
|
return throughput
|
|
|
|
|
|
def _generate_schema(params=None):
|
|
if not params:
|
|
params = module.params
|
|
|
|
schema = list()
|
|
hash_key_name = params.get('hash_key_name')
|
|
range_key_name = params.get('range_key_name')
|
|
|
|
if hash_key_name:
|
|
entry = _schema_dict(hash_key_name, 'HASH')
|
|
schema.append(entry)
|
|
if range_key_name:
|
|
entry = _schema_dict(range_key_name, 'RANGE')
|
|
schema.append(entry)
|
|
|
|
return schema
|
|
|
|
|
|
def _primary_index_changes(current_table):
|
|
|
|
primary_index = _decode_primary_index(current_table)
|
|
|
|
hash_key_name = primary_index.get('hash_key_name')
|
|
_hash_key_name = module.params.get('hash_key_name')
|
|
hash_key_type = primary_index.get('hash_key_type')
|
|
_hash_key_type = module.params.get('hash_key_type')
|
|
range_key_name = primary_index.get('range_key_name')
|
|
_range_key_name = module.params.get('range_key_name')
|
|
range_key_type = primary_index.get('range_key_type')
|
|
_range_key_type = module.params.get('range_key_type')
|
|
|
|
changed = list()
|
|
|
|
if _hash_key_name and (_hash_key_name != hash_key_name):
|
|
changed.append('hash_key_name')
|
|
if _hash_key_type and (_hash_key_type != hash_key_type):
|
|
changed.append('hash_key_type')
|
|
if _range_key_name and (_range_key_name != range_key_name):
|
|
changed.append('range_key_name')
|
|
if _range_key_type and (_range_key_type != range_key_type):
|
|
changed.append('range_key_type')
|
|
|
|
return changed
|
|
|
|
|
|
def _throughput_changes(current_table, params=None):
|
|
|
|
if not params:
|
|
params = module.params
|
|
|
|
throughput = current_table.get('provisioned_throughput', {})
|
|
read_capacity = throughput.get('read_capacity_units', None)
|
|
_read_capacity = params.get('read_capacity') or read_capacity
|
|
write_capacity = throughput.get('write_capacity_units', None)
|
|
_write_capacity = params.get('write_capacity') or write_capacity
|
|
|
|
if (read_capacity != _read_capacity) or (write_capacity != _write_capacity):
|
|
return dict(
|
|
ReadCapacityUnits=_read_capacity,
|
|
WriteCapacityUnits=_write_capacity,
|
|
)
|
|
|
|
return dict()
|
|
|
|
|
|
def _generate_global_indexes(billing_mode):
|
|
index_exists = dict()
|
|
indexes = list()
|
|
|
|
include_throughput = True
|
|
|
|
if billing_mode == "PAY_PER_REQUEST":
|
|
include_throughput = False
|
|
|
|
for index in module.params.get('indexes'):
|
|
if index.get('type') not in ['global_all', 'global_include', 'global_keys_only']:
|
|
continue
|
|
name = index.get('name')
|
|
if name in index_exists:
|
|
module.fail_json(msg='Duplicate key {0} in list of global indexes'.format(name))
|
|
# Convert the type name to upper case and remove the global_
|
|
index['type'] = index['type'].upper()[7:]
|
|
index = _generate_index(index, include_throughput)
|
|
index_exists[name] = True
|
|
indexes.append(index)
|
|
|
|
return indexes
|
|
|
|
|
|
def _generate_local_indexes():
|
|
index_exists = dict()
|
|
indexes = list()
|
|
|
|
for index in module.params.get('indexes'):
|
|
index = dict()
|
|
if index.get('type') not in ['all', 'include', 'keys_only']:
|
|
continue
|
|
name = index.get('name')
|
|
if name in index_exists:
|
|
module.fail_json(msg='Duplicate key {0} in list of local indexes'.format(name))
|
|
index['type'] = index['type'].upper()
|
|
index = _generate_index(index, False)
|
|
index_exists[name] = True
|
|
indexes.append(index)
|
|
|
|
return indexes
|
|
|
|
|
|
def _generate_global_index_map(current_table):
|
|
global_index_map = dict()
|
|
existing_indexes = current_table['_global_index_map']
|
|
for index in module.params.get('indexes'):
|
|
if index.get('type') not in ['global_all', 'global_include', 'global_keys_only']:
|
|
continue
|
|
name = index.get('name')
|
|
if name in global_index_map:
|
|
module.fail_json(msg='Duplicate key {0} in list of global indexes'.format(name))
|
|
idx = _merge_index_params(index, existing_indexes.get(name, {}))
|
|
# Convert the type name to upper case and remove the global_
|
|
idx['type'] = idx['type'].upper()[7:]
|
|
global_index_map[name] = idx
|
|
return global_index_map
|
|
|
|
|
|
def _generate_local_index_map(current_table):
|
|
local_index_map = dict()
|
|
existing_indexes = current_table['_local_index_map']
|
|
for index in module.params.get('indexes'):
|
|
if index.get('type') not in ['all', 'include', 'keys_only']:
|
|
continue
|
|
name = index.get('name')
|
|
if name in local_index_map:
|
|
module.fail_json(msg='Duplicate key {0} in list of local indexes'.format(name))
|
|
idx = _merge_index_params(index, existing_indexes.get(name, {}))
|
|
# Convert the type name to upper case
|
|
idx['type'] = idx['type'].upper()
|
|
local_index_map[name] = idx
|
|
return local_index_map
|
|
|
|
|
|
def _generate_index(index, include_throughput=True):
|
|
key_schema = _generate_schema(index)
|
|
throughput = _generate_throughput(index)
|
|
non_key_attributes = index['includes'] or []
|
|
projection = dict(
|
|
ProjectionType=index['type'],
|
|
)
|
|
if index['type'] != 'ALL':
|
|
projection['NonKeyAttributes'] = non_key_attributes
|
|
else:
|
|
if non_key_attributes:
|
|
module.deprecate(
|
|
"DynamoDB does not support specifying non-key-attributes ('includes') for "
|
|
"indexes of type 'all'. Attempts to set this attributes are currently "
|
|
"ignored, but in future will result in a failure. "
|
|
"Index name: {0}".format(index['name']),
|
|
version='3.0.0', collection_name='community.aws')
|
|
|
|
idx = dict(
|
|
IndexName=index['name'],
|
|
KeySchema=key_schema,
|
|
Projection=projection,
|
|
)
|
|
|
|
if include_throughput:
|
|
idx['ProvisionedThroughput'] = throughput
|
|
|
|
return idx
|
|
|
|
|
|
def _attribute_changes(current_table):
|
|
# TODO (future) It would be nice to catch attempts to change types here.
|
|
return _generate_attributes()
|
|
|
|
|
|
def _global_index_changes(current_table):
|
|
current_global_index_map = current_table['_global_index_map']
|
|
global_index_map = _generate_global_index_map(current_table)
|
|
|
|
current_billing_mode = current_table.get('billing_mode')
|
|
|
|
if module.params.get('billing_mode') is None:
|
|
billing_mode = current_billing_mode
|
|
else:
|
|
billing_mode = module.params.get('billing_mode')
|
|
|
|
include_throughput = True
|
|
|
|
if billing_mode == "PAY_PER_REQUEST":
|
|
include_throughput = False
|
|
|
|
index_changes = list()
|
|
|
|
# TODO (future) it would be nice to add support for deleting an index
|
|
for name in global_index_map:
|
|
|
|
idx = dict(_generate_index(global_index_map[name], include_throughput=include_throughput))
|
|
if name not in current_global_index_map:
|
|
index_changes.append(dict(Create=idx))
|
|
else:
|
|
# The only thing we can change is the provisioned throughput.
|
|
# TODO (future) it would be nice to throw a deprecation here
|
|
# rather than dropping other changes on the floor
|
|
_current = current_global_index_map[name]
|
|
_new = global_index_map[name]
|
|
|
|
if include_throughput:
|
|
change = dict(_throughput_changes(_current, _new))
|
|
if change:
|
|
update = dict(
|
|
IndexName=name,
|
|
ProvisionedThroughput=change,
|
|
)
|
|
index_changes.append(dict(Update=update))
|
|
|
|
return index_changes
|
|
|
|
|
|
def _local_index_changes(current_table):
|
|
# TODO (future) Changes to Local Indexes aren't possible after creation,
|
|
# we should probably throw a deprecation warning here (original module
|
|
# also just dropped these changes on the floor)
|
|
return []
|
|
|
|
|
|
def _update_table(current_table):
|
|
changes = dict()
|
|
additional_global_index_changes = list()
|
|
|
|
throughput_changes = _throughput_changes(current_table)
|
|
if throughput_changes:
|
|
changes['ProvisionedThroughput'] = throughput_changes
|
|
|
|
current_billing_mode = current_table.get('billing_mode')
|
|
new_billing_mode = module.params.get('billing_mode')
|
|
|
|
if new_billing_mode is None:
|
|
new_billing_mode = current_billing_mode
|
|
|
|
if current_billing_mode != new_billing_mode:
|
|
changes['BillingMode'] = new_billing_mode
|
|
|
|
global_index_changes = _global_index_changes(current_table)
|
|
if global_index_changes:
|
|
changes['GlobalSecondaryIndexUpdates'] = global_index_changes
|
|
# Only one index can be changed at a time except if changing the billing mode, pass the first during the
|
|
# main update and deal with the others on a slow retry to wait for
|
|
# completion
|
|
|
|
if current_billing_mode == new_billing_mode:
|
|
if len(global_index_changes) > 1:
|
|
changes['GlobalSecondaryIndexUpdates'] = [global_index_changes[0]]
|
|
additional_global_index_changes = global_index_changes[1:]
|
|
|
|
local_index_changes = _local_index_changes(current_table)
|
|
if local_index_changes:
|
|
changes['LocalSecondaryIndexUpdates'] = local_index_changes
|
|
|
|
if not changes:
|
|
return False
|
|
|
|
if module.check_mode:
|
|
return True
|
|
|
|
if global_index_changes or local_index_changes:
|
|
changes['AttributeDefinitions'] = _generate_attributes()
|
|
|
|
try:
|
|
client.update_table(
|
|
aws_retry=True,
|
|
TableName=module.params.get('name'),
|
|
**changes
|
|
)
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
|
|
module.fail_json_aws(e, msg="Failed to update table")
|
|
|
|
if additional_global_index_changes:
|
|
for index in additional_global_index_changes:
|
|
try:
|
|
_update_table_with_long_retry(GlobalSecondaryIndexUpdates=[index], AttributeDefinitions=changes['AttributeDefinitions'])
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
|
|
module.fail_json_aws(e, msg="Failed to update table", changes=changes,
|
|
additional_global_index_changes=additional_global_index_changes)
|
|
|
|
if module.params.get('wait'):
|
|
wait_exists()
|
|
|
|
return True
|
|
|
|
|
|
def _update_tags(current_table):
|
|
_tags = module.params.get('tags')
|
|
if _tags is None:
|
|
return False
|
|
|
|
tags_to_add, tags_to_remove = compare_aws_tags(current_table['tags'], module.params.get('tags'),
|
|
purge_tags=module.params.get('purge_tags'))
|
|
|
|
# If neither need updating we can return already
|
|
if not (tags_to_add or tags_to_remove):
|
|
return False
|
|
|
|
if module.check_mode:
|
|
return True
|
|
|
|
if tags_to_add:
|
|
try:
|
|
client.tag_resource(
|
|
aws_retry=True,
|
|
ResourceArn=current_table['arn'],
|
|
Tags=ansible_dict_to_boto3_tag_list(tags_to_add),
|
|
)
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
|
|
module.fail_json_aws(e, msg="Failed to tag table")
|
|
if tags_to_remove:
|
|
try:
|
|
client.untag_resource(
|
|
aws_retry=True,
|
|
ResourceArn=current_table['arn'],
|
|
TagKeys=tags_to_remove,
|
|
)
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
|
|
module.fail_json_aws(e, msg="Failed to untag table")
|
|
|
|
return True
|
|
|
|
|
|
def update_table(current_table):
|
|
primary_index_changes = _primary_index_changes(current_table)
|
|
if primary_index_changes:
|
|
module.deprecate("DynamoDB does not support updating the Primary keys on a table. "
|
|
"Attempts to change the keys are currently ignored, but in future will "
|
|
"result in a failure. "
|
|
"Changed paramters are {0}".format(primary_index_changes),
|
|
version='3.0.0', collection_name='community.aws')
|
|
|
|
changed = False
|
|
changed |= _update_table(current_table)
|
|
changed |= _update_tags(current_table)
|
|
|
|
if module.params.get('wait'):
|
|
wait_exists()
|
|
|
|
return changed
|
|
|
|
|
|
def create_table():
|
|
table_name = module.params.get('name')
|
|
hash_key_name = module.params.get('hash_key_name')
|
|
billing_mode = module.params.get('billing_mode')
|
|
|
|
if billing_mode is None:
|
|
billing_mode = "PROVISIONED"
|
|
|
|
tags = ansible_dict_to_boto3_tag_list(module.params.get('tags') or {})
|
|
|
|
if not hash_key_name:
|
|
module.fail_json('"hash_key_name" must be provided when creating a new table.')
|
|
|
|
if module.check_mode:
|
|
return True
|
|
|
|
if billing_mode == "PROVISIONED":
|
|
throughput = _generate_throughput()
|
|
|
|
attributes = _generate_attributes()
|
|
key_schema = _generate_schema()
|
|
local_indexes = _generate_local_indexes()
|
|
global_indexes = _generate_global_indexes(billing_mode)
|
|
|
|
params = dict(
|
|
TableName=table_name,
|
|
AttributeDefinitions=attributes,
|
|
KeySchema=key_schema,
|
|
Tags=tags,
|
|
BillingMode=billing_mode
|
|
# TODO (future)
|
|
# StreamSpecification,
|
|
# SSESpecification,
|
|
)
|
|
|
|
if billing_mode == "PROVISIONED":
|
|
params['ProvisionedThroughput'] = throughput
|
|
if local_indexes:
|
|
params['LocalSecondaryIndexes'] = local_indexes
|
|
if global_indexes:
|
|
params['GlobalSecondaryIndexes'] = global_indexes
|
|
|
|
try:
|
|
client.create_table(aws_retry=True, **params)
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
|
|
module.fail_json_aws(e, msg='Failed to create table')
|
|
|
|
if module.params.get('wait'):
|
|
wait_exists()
|
|
|
|
return True
|
|
|
|
|
|
def delete_table(current_table):
|
|
if not current_table:
|
|
return False
|
|
|
|
if module.check_mode:
|
|
return True
|
|
|
|
table_name = module.params.get('name')
|
|
|
|
# If an index is mid-update then we have to wait for the update to complete
|
|
# before deletion will succeed
|
|
long_retry = AWSRetry.jittered_backoff(
|
|
retries=45, delay=5, max_delay=30,
|
|
catch_extra_error_codes=['LimitExceededException', 'ResourceInUseException'],
|
|
)
|
|
|
|
try:
|
|
long_retry(client.delete_table)(TableName=table_name)
|
|
except is_boto3_error_code('ResourceNotFoundException'):
|
|
return False
|
|
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
|
|
module.fail_json_aws(e, msg='Failed to delete table')
|
|
|
|
if module.params.get('wait'):
|
|
wait_not_exists()
|
|
|
|
return True
|
|
|
|
|
|
def main():
|
|
|
|
global module
|
|
global client
|
|
|
|
# TODO (future) It would be good to split global and local indexes. They have
|
|
# different parameters, use a separate namespace for names,
|
|
# and local indexes can't be updated.
|
|
index_options = dict(
|
|
name=dict(type='str', required=True),
|
|
# It would be nice to make this optional, but because Local and Global
|
|
# indexes are mixed in here we need this to be able to tell to which
|
|
# group of indexes the index belongs.
|
|
type=dict(type='str', required=True, choices=INDEX_TYPE_OPTIONS),
|
|
hash_key_name=dict(type='str', required=False),
|
|
hash_key_type=dict(type='str', required=False, choices=KEY_TYPE_CHOICES),
|
|
range_key_name=dict(type='str', required=False),
|
|
range_key_type=dict(type='str', required=False, choices=KEY_TYPE_CHOICES),
|
|
includes=dict(type='list', required=False, elements='str'),
|
|
read_capacity=dict(type='int', required=False),
|
|
write_capacity=dict(type='int', required=False),
|
|
)
|
|
|
|
argument_spec = dict(
|
|
state=dict(default='present', choices=['present', 'absent']),
|
|
name=dict(required=True, type='str'),
|
|
hash_key_name=dict(type='str'),
|
|
hash_key_type=dict(type='str', choices=KEY_TYPE_CHOICES),
|
|
range_key_name=dict(type='str'),
|
|
range_key_type=dict(type='str', choices=KEY_TYPE_CHOICES),
|
|
billing_mode=dict(type='str', choices=['PROVISIONED', 'PAY_PER_REQUEST']),
|
|
read_capacity=dict(type='int'),
|
|
write_capacity=dict(type='int'),
|
|
indexes=dict(default=[], type='list', elements='dict', options=index_options),
|
|
tags=dict(type='dict'),
|
|
purge_tags=dict(type='bool', default=True),
|
|
wait=dict(type='bool', default=True),
|
|
wait_timeout=dict(default=300, type='int', aliases=['wait_for_active_timeout']),
|
|
)
|
|
|
|
module = AnsibleAWSModule(
|
|
argument_spec=argument_spec,
|
|
supports_check_mode=True,
|
|
check_boto3=False,
|
|
)
|
|
|
|
retry_decorator = AWSRetry.jittered_backoff(
|
|
catch_extra_error_codes=['LimitExceededException', 'ResourceInUseException', 'ResourceNotFoundException'],
|
|
)
|
|
client = module.client('dynamodb', retry_decorator=retry_decorator)
|
|
|
|
current_table = get_dynamodb_table()
|
|
changed = False
|
|
table = None
|
|
results = dict()
|
|
|
|
state = module.params.get('state')
|
|
if state == 'present':
|
|
if current_table:
|
|
changed |= update_table(current_table)
|
|
else:
|
|
changed |= create_table()
|
|
table = get_dynamodb_table()
|
|
elif state == 'absent':
|
|
changed |= delete_table(current_table)
|
|
|
|
compat_results = compatability_results(table)
|
|
if compat_results:
|
|
results.update(compat_results)
|
|
|
|
results['changed'] = changed
|
|
if table:
|
|
# These are used to pass computed data about, not needed for users
|
|
table.pop('_global_index_map', None)
|
|
table.pop('_local_index_map', None)
|
|
results['table'] = table
|
|
|
|
module.exit_json(**results)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|