From db0738025a79993c550d9d469e88dfed5d24609a Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Tue, 9 Nov 2021 21:28:24 -0100 Subject: [PATCH] SSM - Maintenance Windows (#4549) --- IMPLEMENTATION_COVERAGE.md | 10 +- docs/docs/services/ssm.rst | 27 ++- moto/ssm/models.py | 107 ++++++++++++ moto/ssm/responses.py | 43 +++++ .../test_ssm/test_ssm_maintenance_windows.py | 155 ++++++++++++++++++ 5 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 tests/test_ssm/test_ssm_maintenance_windows.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 8561cdcbe..52007c800 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4196,7 +4196,7 @@ ## ssm
-17% implemented +20% implemented - [X] add_tags_to_resource - [ ] associate_ops_item_related_item @@ -4206,7 +4206,7 @@ - [ ] create_association - [ ] create_association_batch - [X] create_document -- [ ] create_maintenance_window +- [X] create_maintenance_window - [ ] create_ops_item - [ ] create_ops_metadata - [ ] create_patch_baseline @@ -4215,7 +4215,7 @@ - [ ] delete_association - [X] delete_document - [ ] delete_inventory -- [ ] delete_maintenance_window +- [X] delete_maintenance_window - [ ] delete_ops_metadata - [X] delete_parameter - [X] delete_parameters @@ -4248,7 +4248,7 @@ - [ ] describe_maintenance_window_schedule - [ ] describe_maintenance_window_targets - [ ] describe_maintenance_window_tasks -- [ ] describe_maintenance_windows +- [X] describe_maintenance_windows - [ ] describe_maintenance_windows_for_target - [ ] describe_ops_items - [X] describe_parameters @@ -4267,7 +4267,7 @@ - [X] get_document - [ ] get_inventory - [ ] get_inventory_schema -- [ ] get_maintenance_window +- [X] get_maintenance_window - [ ] get_maintenance_window_execution - [ ] get_maintenance_window_execution_task - [ ] get_maintenance_window_execution_task_invocation diff --git a/docs/docs/services/ssm.rst b/docs/docs/services/ssm.rst index a1f866590..368dbe412 100644 --- a/docs/docs/services/ssm.rst +++ b/docs/docs/services/ssm.rst @@ -35,7 +35,11 @@ ssm - [ ] create_association - [ ] create_association_batch - [X] create_document -- [ ] create_maintenance_window +- [X] create_maintenance_window + + Creates a maintenance window. No error handling or input validation has been implemented yet. + + - [ ] create_ops_item - [ ] create_ops_metadata - [ ] create_patch_baseline @@ -44,7 +48,11 @@ ssm - [ ] delete_association - [X] delete_document - [ ] delete_inventory -- [ ] delete_maintenance_window +- [X] delete_maintenance_window + + Assumes the provided WindowId exists. No error handling has been implemented yet. + + - [ ] delete_ops_metadata - [X] delete_parameter - [X] delete_parameters @@ -77,7 +85,13 @@ ssm - [ ] describe_maintenance_window_schedule - [ ] describe_maintenance_window_targets - [ ] describe_maintenance_window_tasks -- [ ] describe_maintenance_windows +- [X] describe_maintenance_windows + + Returns all windows. No pagination has been implemented yet. Only filtering for Name is supported. + The NextExecutionTime-field is not returned. + + + - [ ] describe_maintenance_windows_for_target - [ ] describe_ops_items - [X] describe_parameters @@ -100,7 +114,12 @@ ssm - [X] get_document - [ ] get_inventory - [ ] get_inventory_schema -- [ ] get_maintenance_window +- [X] get_maintenance_window + + The window is assumed to exist - no error handling has been implemented yet. + The NextExecutionTime-field is not returned. + + - [ ] get_maintenance_window_execution - [ ] get_maintenance_window_execution_task - [ ] get_maintenance_window_execution_task_invocation diff --git a/moto/ssm/models.py b/moto/ssm/models.py index 7764c812c..27cb0118c 100644 --- a/moto/ssm/models.py +++ b/moto/ssm/models.py @@ -15,6 +15,7 @@ import uuid import json import yaml import hashlib +import random from .utils import parameter_arn from .exceptions import ( @@ -669,6 +670,53 @@ def _valid_parameter_data_type(data_type): return data_type in ("text", "aws:ec2:image") +class FakeMaintenanceWindow: + def __init__( + self, + name, + description, + enabled, + duration, + cutoff, + schedule, + schedule_timezone, + schedule_offset, + start_date, + end_date, + ): + self.id = FakeMaintenanceWindow.generate_id() + self.name = name + self.description = description + self.enabled = enabled + self.duration = duration + self.cutoff = cutoff + self.schedule = schedule + self.schedule_timezone = schedule_timezone + self.schedule_offset = schedule_offset + self.start_date = start_date + self.end_date = end_date + + def to_json(self): + return { + "WindowId": self.id, + "Name": self.name, + "Description": self.description, + "Enabled": self.enabled, + "Duration": self.duration, + "Cutoff": self.cutoff, + "Schedule": self.schedule, + "ScheduleTimezone": self.schedule_timezone, + "ScheduleOffset": self.schedule_offset, + "StartDate": self.start_date, + "EndDate": self.end_date, + } + + @staticmethod + def generate_id(): + chars = list(range(10)) + ["a", "b", "c", "d", "e", "f"] + return "mw-" + "".join(str(random.choice(chars)) for _ in range(17)) + + class SimpleSystemManagerBackend(BaseBackend): def __init__(self, region_name=None): super(SimpleSystemManagerBackend, self).__init__() @@ -681,6 +729,8 @@ class SimpleSystemManagerBackend(BaseBackend): self._errors = [] self._documents: Dict[str, Documents] = {} + self.windows: Dict[str, FakeMaintenanceWindow] = dict() + self._region = region_name def reset(self): @@ -1735,6 +1785,63 @@ class SimpleSystemManagerBackend(BaseBackend): command = self.get_command_by_id(command_id) return command.get_invocation(instance_id, plugin_name) + def create_maintenance_window( + self, + name, + description, + enabled, + duration, + cutoff, + schedule, + schedule_timezone, + schedule_offset, + start_date, + end_date, + ): + """ + Creates a maintenance window. No error handling or input validation has been implemented yet. + """ + window = FakeMaintenanceWindow( + name, + description, + enabled, + duration, + cutoff, + schedule, + schedule_timezone, + schedule_offset, + start_date, + end_date, + ) + self.windows[window.id] = window + return window.id + + def get_maintenance_window(self, window_id): + """ + The window is assumed to exist - no error handling has been implemented yet. + The NextExecutionTime-field is not returned. + """ + return self.windows[window_id] + + def describe_maintenance_windows(self, filters): + """ + Returns all windows. No pagination has been implemented yet. Only filtering for Name is supported. + The NextExecutionTime-field is not returned. + + """ + res = [window for window in self.windows.values()] + if filters: + for f in filters: + if f["Key"] == "Name": + res = [w for w in res if w.name in f["Values"]] + return res + + def delete_maintenance_window(self, window_id): + """ + Assumes the provided WindowId exists. No error handling has been implemented yet. + """ + del self.windows[window_id] + ssm_backends = {} for region in Session().get_available_regions("ssm"): diff --git a/moto/ssm/responses.py b/moto/ssm/responses.py index b4b5892eb..96110a3ad 100644 --- a/moto/ssm/responses.py +++ b/moto/ssm/responses.py @@ -379,3 +379,46 @@ class SimpleSystemManagerResponse(BaseResponse): return json.dumps( self.ssm_backend.get_command_invocation(**self.request_params) ) + + def create_maintenance_window(self): + name = self._get_param("Name") + desc = self._get_param("Description", None) + enabled = self._get_bool_param("Enabled", True) + duration = self._get_int_param("Duration") + cutoff = self._get_int_param("Cutoff") + schedule = self._get_param("Schedule") + schedule_timezone = self._get_param("ScheduleTimezone") + schedule_offset = self._get_int_param("ScheduleOffset") + start_date = self._get_param("StartDate") + end_date = self._get_param("EndDate") + window_id = self.ssm_backend.create_maintenance_window( + name=name, + description=desc, + enabled=enabled, + duration=duration, + cutoff=cutoff, + schedule=schedule, + schedule_timezone=schedule_timezone, + schedule_offset=schedule_offset, + start_date=start_date, + end_date=end_date, + ) + return json.dumps({"WindowId": window_id}) + + def get_maintenance_window(self): + window_id = self._get_param("WindowId") + window = self.ssm_backend.get_maintenance_window(window_id) + return json.dumps(window.to_json()) + + def describe_maintenance_windows(self): + filters = self._get_param("Filters", None) + windows = [ + window.to_json() + for window in self.ssm_backend.describe_maintenance_windows(filters) + ] + return json.dumps({"WindowIdentities": windows}) + + def delete_maintenance_window(self): + window_id = self._get_param("WindowId") + self.ssm_backend.delete_maintenance_window(window_id) + return "{}" diff --git a/tests/test_ssm/test_ssm_maintenance_windows.py b/tests/test_ssm/test_ssm_maintenance_windows.py new file mode 100644 index 000000000..af92f77cb --- /dev/null +++ b/tests/test_ssm/test_ssm_maintenance_windows.py @@ -0,0 +1,155 @@ +import boto3 +import sure # noqa # pylint: disable=unused-import + +from moto import mock_ssm + + +@mock_ssm +def test_describe_maintenance_window(): + ssm = boto3.client("ssm", region_name="us-east-1") + + resp = ssm.describe_maintenance_windows() + resp.should.have.key("WindowIdentities").equals([]) + + resp = ssm.describe_maintenance_windows( + Filters=[{"Key": "Name", "Values": ["fake-maintenance-window-name",]},], + ) + resp.should.have.key("WindowIdentities").equals([]) + + +@mock_ssm +def test_create_maintenance_windows_simple(): + ssm = boto3.client("ssm", region_name="us-east-1") + + resp = ssm.create_maintenance_window( + Name="simple-window", + Schedule="cron(15 12 * * ? *)", + Duration=2, + Cutoff=1, + AllowUnassociatedTargets=False, + ) + resp.should.have.key("WindowId") + _id = resp["WindowId"] # mw-01d6bbfdf6af2c39a + + resp = ssm.describe_maintenance_windows() + resp.should.have.key("WindowIdentities").have.length_of(1) + + my_window = resp["WindowIdentities"][0] + my_window.should.have.key("WindowId").equal(_id) + my_window.should.have.key("Name").equal("simple-window") + my_window.should.have.key("Enabled").equal(True) + my_window.should.have.key("Duration").equal(2) + my_window.should.have.key("Cutoff").equal(1) + my_window.should.have.key("Schedule").equal("cron(15 12 * * ? *)") + # my_window.should.have.key("NextExecutionTime") + my_window.shouldnt.have.key("Description") + my_window.shouldnt.have.key("ScheduleTimezone") + my_window.shouldnt.have.key("ScheduleOffset") + my_window.shouldnt.have.key("EndDate") + my_window.shouldnt.have.key("StartDate") + + +@mock_ssm +def test_create_maintenance_windows_advanced(): + ssm = boto3.client("ssm", region_name="us-east-1") + + resp = ssm.create_maintenance_window( + Name="simple-window", + Description="French windows are just too fancy", + Schedule="cron(15 12 * * ? *)", + ScheduleTimezone="Europe/London", + ScheduleOffset=1, + Duration=5, + Cutoff=4, + AllowUnassociatedTargets=False, + StartDate="2021-11-01", + EndDate="2021-12-31", + ) + resp.should.have.key("WindowId") + _id = resp["WindowId"] # mw-01d6bbfdf6af2c39a + + resp = ssm.describe_maintenance_windows() + resp.should.have.key("WindowIdentities").have.length_of(1) + + my_window = resp["WindowIdentities"][0] + my_window.should.have.key("WindowId").equal(_id) + my_window.should.have.key("Name").equal("simple-window") + my_window.should.have.key("Enabled").equal(True) + my_window.should.have.key("Duration").equal(5) + my_window.should.have.key("Cutoff").equal(4) + my_window.should.have.key("Schedule").equal("cron(15 12 * * ? *)") + # my_window.should.have.key("NextExecutionTime") + my_window.should.have.key("Description").equals("French windows are just too fancy") + my_window.should.have.key("ScheduleTimezone").equals("Europe/London") + my_window.should.have.key("ScheduleOffset").equals(1) + my_window.should.have.key("StartDate").equals("2021-11-01") + my_window.should.have.key("EndDate").equals("2021-12-31") + + +@mock_ssm +def test_get_maintenance_windows(): + ssm = boto3.client("ssm", region_name="us-east-1") + + resp = ssm.create_maintenance_window( + Name="my-window", + Schedule="cron(15 12 * * ? *)", + Duration=2, + Cutoff=1, + AllowUnassociatedTargets=False, + ) + resp.should.have.key("WindowId") + _id = resp["WindowId"] # mw-01d6bbfdf6af2c39a + + my_window = ssm.get_maintenance_window(WindowId=_id) + my_window.should.have.key("WindowId").equal(_id) + my_window.should.have.key("Name").equal("my-window") + my_window.should.have.key("Enabled").equal(True) + my_window.should.have.key("Duration").equal(2) + my_window.should.have.key("Cutoff").equal(1) + my_window.should.have.key("Schedule").equal("cron(15 12 * * ? *)") + # my_window.should.have.key("NextExecutionTime") + my_window.shouldnt.have.key("Description") + my_window.shouldnt.have.key("ScheduleTimezone") + my_window.shouldnt.have.key("ScheduleOffset") + my_window.shouldnt.have.key("EndDate") + my_window.shouldnt.have.key("StartDate") + + +@mock_ssm +def test_describe_maintenance_windows(): + ssm = boto3.client("ssm", region_name="us-east-1") + + for idx in range(0, 4): + ssm.create_maintenance_window( + Name=f"window_{idx}", + Schedule="cron(15 12 * * ? *)", + Duration=2, + Cutoff=1, + AllowUnassociatedTargets=False, + ) + + resp = ssm.describe_maintenance_windows() + resp.should.have.key("WindowIdentities").have.length_of(4) + + resp = ssm.describe_maintenance_windows( + Filters=[{"Key": "Name", "Values": ["window_0", "window_2",]},], + ) + resp.should.have.key("WindowIdentities").have.length_of(2) + + +@mock_ssm +def test_delete_maintenance_windows(): + ssm = boto3.client("ssm", region_name="us-east-1") + + resp = ssm.create_maintenance_window( + Name="simple-window", + Schedule="cron(15 12 * * ? *)", + Duration=2, + Cutoff=1, + AllowUnassociatedTargets=False, + ) + + ssm.delete_maintenance_window(WindowId=(resp["WindowId"])) + + resp = ssm.describe_maintenance_windows() + resp.should.have.key("WindowIdentities").equals([])