From a974a3dfe4204e0f0eb690726754a5434486f053 Mon Sep 17 00:00:00 2001 From: Mike Liu Date: Wed, 25 Apr 2018 16:22:10 -0400 Subject: [PATCH 1/4] Create a Command class for the ssm backend. This class will make it easier to keep track of commands in a list for the SSM backend later on when we implement the ListCommands API call. --- moto/ssm/models.py | 113 +++++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index fc74e1524..4f1bca213 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -58,11 +58,74 @@ class Parameter(BaseModel): return r +MAX_TIMEOUT_SECONDS = 3600 + + +class Command(BaseModel): + def __init__(self, comment='', document_name='', timeout_seconds=MAX_TIMEOUT_SECONDS, + instance_ids=[], max_concurrency='', max_errors='', + notification_config={}, output_s3_bucket_name='', + output_s3_key_prefix='', output_s3_region='', parameters={}, + service_role_arn='', targets=[]): + + self.error_count = 0 + self.completed_count = len(instance_ids) + self.target_count = len(instance_ids) + self.command_id = str(uuid.uuid4()) + self.status = 'Success' + self.status_details = 'Details placeholder' + + now = datetime.datetime.now() + self.requested_date_time = now.isoformat() + expires_after = now + datetime.timedelta(0, timeout_seconds) + self.expires_after = expires_after.isoformat() + + self.comment = comment + self.document_name = document_name + self.instance_ids = instance_ids + self.max_concurrency = max_concurrency + self.max_errors = max_errors + self.notification_config = notification_config + self.output_s3_bucket_name = output_s3_bucket_name + self.output_s3_key_prefix = output_s3_key_prefix + self.output_s3_region = output_s3_region + self.parameters = parameters + self.service_role_arn = service_role_arn + self.targets = targets + + def response_object(self): + r = { + 'CommandId': self.command_id, + 'Comment': self.comment, + 'CompletedCount': self.completed_count, + 'DocumentName': self.document_name, + 'ErrorCount': self.error_count, + 'ExpiresAfter': self.expires_after, + 'InstanceIds': self.instance_ids, + 'MaxConcurrency': self.max_concurrency, + 'MaxErrors': self.max_errors, + 'NotificationConfig': self.notification_config, + 'OutputS3Region': self.output_s3_region, + 'OutputS3BucketName': self.output_s3_bucket_name, + 'OutputS3KeyPrefix': self.output_s3_key_prefix, + 'Parameters': self.parameters, + 'RequestedDateTime': self.requested_date_time, + 'ServiceRole': self.service_role_arn, + 'Status': self.status, + 'StatusDetails': self.status_details, + 'TargetCount': self.target_count, + 'Targets': self.targets, + } + + return r + + class SimpleSystemManagerBackend(BaseBackend): def __init__(self): self._parameters = {} self._resource_tags = defaultdict(lambda: defaultdict(dict)) + self._commands = [] def delete_parameter(self, name): try: @@ -142,36 +205,28 @@ class SimpleSystemManagerBackend(BaseBackend): return self._resource_tags[resource_type][resource_id] def send_command(self, **kwargs): - instances = kwargs.get('InstanceIds', []) - now = datetime.datetime.now() - expires_after = now + datetime.timedelta(0, int(kwargs.get('TimeoutSeconds', 3600))) + command = Command( + comment=kwargs.get('Comment', ''), + document_name=kwargs.get('DocumentName'), + timeout_seconds=kwargs.get('TimeoutSeconds', 3600), + instance_ids=kwargs.get('InstanceIds', []), + max_concurrency=kwargs.get('MaxConcurrency', '50'), + max_errors=kwargs.get('MaxErrors', '0'), + notification_config=kwargs.get('NotificationConfig', { + 'NotificationArn': 'string', + 'NotificationEvents': ['Success'], + 'NotificationType': 'Command' + }), + output_s3_bucket_name=kwargs.get('OutputS3BucketName', ''), + output_s3_key_prefix=kwargs.get('OutputS3KeyPrefix', ''), + output_s3_region=kwargs.get('OutputS3Region', ''), + parameters=kwargs.get('Parameters', {}), + service_role_arn=kwargs.get('ServiceRoleArn', ''), + targets=kwargs.get('Targets', [])) + + self._commands.append(command) return { - 'Command': { - 'CommandId': str(uuid.uuid4()), - 'DocumentName': kwargs['DocumentName'], - 'Comment': kwargs.get('Comment'), - 'ExpiresAfter': expires_after.isoformat(), - 'Parameters': kwargs['Parameters'], - 'InstanceIds': kwargs['InstanceIds'], - 'Targets': kwargs.get('targets'), - 'RequestedDateTime': now.isoformat(), - 'Status': 'Success', - 'StatusDetails': 'string', - 'OutputS3Region': kwargs.get('OutputS3Region'), - 'OutputS3BucketName': kwargs.get('OutputS3BucketName'), - 'OutputS3KeyPrefix': kwargs.get('OutputS3KeyPrefix'), - 'MaxConcurrency': 'string', - 'MaxErrors': 'string', - 'TargetCount': len(instances), - 'CompletedCount': len(instances), - 'ErrorCount': 0, - 'ServiceRole': kwargs.get('ServiceRoleArn'), - 'NotificationConfig': { - 'NotificationArn': 'string', - 'NotificationEvents': ['Success'], - 'NotificationType': 'Command' - } - } + 'Command': command.response_object() } From 1016487c78b32fa0a67d093c3a43f39cd68aa231 Mon Sep 17 00:00:00 2001 From: Mike Liu Date: Wed, 25 Apr 2018 16:25:44 -0400 Subject: [PATCH 2/4] Implement the ListCommands API endpoint for the SSM client. Currently only supports getting commands by CommandId and InstanceIds. --- moto/ssm/models.py | 32 ++++++++++++++++++++++++++++++++ moto/ssm/responses.py | 5 +++++ 2 files changed, 37 insertions(+) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index 4f1bca213..688cffcd5 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from collections import defaultdict from moto.core import BaseBackend, BaseModel +from moto.core.exceptions import RESTError from moto.ec2 import ec2_backends import datetime @@ -229,6 +230,37 @@ class SimpleSystemManagerBackend(BaseBackend): 'Command': command.response_object() } + def list_commands(self, **kwargs): + """ + https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_ListCommands.html + """ + commands = self._commands + + command_id = kwargs.get('CommandId', None) + if command_id: + commands = [self.get_command_by_id(command_id)] + instance_id = kwargs.get('InstanceId', None) + if instance_id: + commands = self.get_commands_by_instance_id(instance_id) + + return { + 'Commands': [command.response_object() for command in commands] + } + + def get_command_by_id(self, id): + command = next( + (command for command in self._commands if command.command_id == id), None) + + if command is None: + raise RESTError('InvalidCommandId', 'Invalid command id.') + + return command + + def get_commands_by_instance_id(self, instance_id): + return [ + command for command in self._commands + if instance_id in command.instance_ids] + ssm_backends = {} for region, ec2_backend in ec2_backends.items(): diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py index d9906a82e..e86fe05e3 100644 --- a/moto/ssm/responses.py +++ b/moto/ssm/responses.py @@ -204,3 +204,8 @@ class SimpleSystemManagerResponse(BaseResponse): return json.dumps( self.ssm_backend.send_command(**self.request_params) ) + + def list_commands(self): + return json.dumps( + self.ssm_backend.list_commands(**self.request_params) + ) From a0882316eca4aeaf9b833b97aa4838b509016373 Mon Sep 17 00:00:00 2001 From: Mike Liu Date: Wed, 25 Apr 2018 16:27:07 -0400 Subject: [PATCH 3/4] Tests for ListCommands SSM API endpoint. Test that ListCommands returns commands sent by SendCommand as well as filters by CommandId and InstanceId. In addition update the SendCommand test for optional parameters. --- tests/test_ssm/test_ssm_boto3.py | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_ssm/test_ssm_boto3.py b/tests/test_ssm/test_ssm_boto3.py index 0531d1780..9270a7222 100644 --- a/tests/test_ssm/test_ssm_boto3.py +++ b/tests/test_ssm/test_ssm_boto3.py @@ -4,6 +4,10 @@ import boto3 import botocore.exceptions import sure # noqa import datetime +import uuid + +from botocore.exceptions import ClientError +from nose.tools import assert_raises from moto import mock_ssm @@ -497,3 +501,59 @@ def test_send_command(): cmd['OutputS3KeyPrefix'].should.equal('pref') cmd['ExpiresAfter'].should.be.greater_than(before) + + # test sending a command without any optional parameters + response = client.send_command( + DocumentName=ssm_document) + + cmd = response['Command'] + + cmd['CommandId'].should_not.be(None) + cmd['DocumentName'].should.equal(ssm_document) + + +@mock_ssm +def test_list_commands(): + client = boto3.client('ssm', region_name='us-east-1') + + ssm_document = 'AWS-RunShellScript' + params = {'commands': ['#!/bin/bash\necho \'hello world\'']} + + response = client.send_command( + InstanceIds=['i-123456'], + DocumentName=ssm_document, + Parameters=params, + OutputS3Region='us-east-2', + OutputS3BucketName='the-bucket', + OutputS3KeyPrefix='pref') + + cmd = response['Command'] + cmd_id = cmd['CommandId'] + + # get the command by id + response = client.list_commands( + CommandId=cmd_id) + + cmds = response['Commands'] + len(cmds).should.equal(1) + cmds[0]['CommandId'].should.equal(cmd_id) + + # add another command with the same instance id to test listing by + # instance id + client.send_command( + InstanceIds=['i-123456'], + DocumentName=ssm_document) + + response = client.list_commands( + InstanceId='i-123456') + + cmds = response['Commands'] + len(cmds).should.equal(2) + + for cmd in cmds: + cmd['InstanceIds'].should.contain('i-123456') + + # test the error case for an invalid command id + with assert_raises(ClientError): + response = client.list_commands( + CommandId=str(uuid.uuid4())) From bbf70bf21c4a477a4575d33c337cfe453485f45e Mon Sep 17 00:00:00 2001 From: Mike Liu Date: Mon, 11 Jun 2018 12:28:11 -0400 Subject: [PATCH 4/4] Fix using mutable default arguments. According to http://docs.python-guide.org/en/latest/writing/gotchas/#mutable-default-arguments using mutable default arguments is not a good practice since it doesn't perform intuitively. For example lists and dictionaries as default arguments are initialized ONCE instead of on each invocation of the function. --- moto/ssm/models.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/moto/ssm/models.py b/moto/ssm/models.py index 688cffcd5..f839e2e14 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -64,10 +64,22 @@ MAX_TIMEOUT_SECONDS = 3600 class Command(BaseModel): def __init__(self, comment='', document_name='', timeout_seconds=MAX_TIMEOUT_SECONDS, - instance_ids=[], max_concurrency='', max_errors='', - notification_config={}, output_s3_bucket_name='', - output_s3_key_prefix='', output_s3_region='', parameters={}, - service_role_arn='', targets=[]): + instance_ids=None, max_concurrency='', max_errors='', + notification_config=None, output_s3_bucket_name='', + output_s3_key_prefix='', output_s3_region='', parameters=None, + service_role_arn='', targets=None): + + if instance_ids is None: + instance_ids = [] + + if notification_config is None: + notification_config = {} + + if parameters is None: + parameters = {} + + if targets is None: + targets = [] self.error_count = 0 self.completed_count = len(instance_ids)