diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index fedeb40d1..a00754251 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -6218,13 +6218,13 @@ ## resiliencehub
-18% implemented +31% implemented - [ ] add_draft_app_version_resource_mappings - [ ] batch_update_recommendation_status - [X] create_app -- [ ] create_app_version_app_component -- [ ] create_app_version_resource +- [X] create_app_version_app_component +- [X] create_app_version_resource - [ ] create_recommendation_template - [X] create_resiliency_policy - [ ] delete_app @@ -6243,17 +6243,17 @@ - [ ] describe_app_version_template - [ ] describe_draft_app_version_resources_import_status - [X] describe_resiliency_policy -- [ ] import_resources_to_draft_app_version +- [X] import_resources_to_draft_app_version - [ ] list_alarm_recommendations - [ ] list_app_assessment_compliance_drifts - [X] list_app_assessments - [ ] list_app_component_compliances - [ ] list_app_component_recommendations - [ ] list_app_input_sources -- [ ] list_app_version_app_components +- [X] list_app_version_app_components - [ ] list_app_version_resource_mappings -- [ ] list_app_version_resources -- [ ] list_app_versions +- [X] list_app_version_resources +- [X] list_app_versions - [X] list_apps - [ ] list_recommendation_templates - [X] list_resiliency_policies @@ -6262,7 +6262,7 @@ - [X] list_tags_for_resource - [ ] list_test_recommendations - [ ] list_unsupported_app_version_resources -- [ ] publish_app_version +- [X] publish_app_version - [ ] put_draft_app_version_template - [ ] remove_draft_app_version_resource_mappings - [ ] resolve_app_version_resources diff --git a/docs/docs/services/resiliencehub.rst b/docs/docs/services/resiliencehub.rst index b326081f7..1a0eddf09 100644 --- a/docs/docs/services/resiliencehub.rst +++ b/docs/docs/services/resiliencehub.rst @@ -21,8 +21,8 @@ resiliencehub The ClientToken-parameter is not yet implemented -- [ ] create_app_version_app_component -- [ ] create_app_version_resource +- [X] create_app_version_app_component +- [X] create_app_version_resource - [ ] create_recommendation_template - [X] create_resiliency_policy @@ -45,17 +45,17 @@ resiliencehub - [ ] describe_app_version_template - [ ] describe_draft_app_version_resources_import_status - [X] describe_resiliency_policy -- [ ] import_resources_to_draft_app_version +- [X] import_resources_to_draft_app_version - [ ] list_alarm_recommendations - [ ] list_app_assessment_compliance_drifts - [X] list_app_assessments - [ ] list_app_component_compliances - [ ] list_app_component_recommendations - [ ] list_app_input_sources -- [ ] list_app_version_app_components +- [X] list_app_version_app_components - [ ] list_app_version_resource_mappings -- [ ] list_app_version_resources -- [ ] list_app_versions +- [X] list_app_version_resources +- [X] list_app_versions - [X] list_apps The FromAssessmentTime/ToAssessmentTime-parameters are not yet implemented @@ -68,7 +68,7 @@ resiliencehub - [X] list_tags_for_resource - [ ] list_test_recommendations - [ ] list_unsupported_app_version_resources -- [ ] publish_app_version +- [X] publish_app_version - [ ] put_draft_app_version_template - [ ] remove_draft_app_version_resource_mappings - [ ] resolve_app_version_resources diff --git a/moto/resiliencehub/exceptions.py b/moto/resiliencehub/exceptions.py index 88c400049..8eac01a43 100644 --- a/moto/resiliencehub/exceptions.py +++ b/moto/resiliencehub/exceptions.py @@ -12,6 +12,11 @@ class AppNotFound(ResourceNotFound): super().__init__(f"App not found for appArn {arn}") +class AppVersionNotFound(ResourceNotFound): + def __init__(self) -> None: + super().__init__("App Version not found") + + class ResiliencyPolicyNotFound(ResourceNotFound): def __init__(self, arn: str): super().__init__(f"ResiliencyPolicy {arn} not found") diff --git a/moto/resiliencehub/models.py b/moto/resiliencehub/models.py index 173ed2437..8a5cf568d 100644 --- a/moto/resiliencehub/models.py +++ b/moto/resiliencehub/models.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from moto.core.base_backend import BackendDict, BaseBackend from moto.core.common_models import BaseModel @@ -7,7 +7,7 @@ from moto.moto_api._internal import mock_random from moto.utilities.paginator import paginate from moto.utilities.tagging_service import TaggingService -from .exceptions import AppNotFound, ResiliencyPolicyNotFound +from .exceptions import AppNotFound, AppVersionNotFound, ResiliencyPolicyNotFound PAGINATION_MODEL = { "list_apps": { @@ -25,6 +25,20 @@ PAGINATION_MODEL = { } +class AppComponent(BaseModel): + def __init__(self, _id: str, name: str, _type: str): + self.id = _id + self.name = name + self.type = _type + + def to_json(self) -> Dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "type": self.type, + } + + class App(BaseModel): def __init__( self, @@ -48,6 +62,16 @@ class App(BaseModel): self.policy_arn = policy_arn self.resilience_score = 0.0 self.status = "Active" + self.app_versions: List[AppVersion] = [] + + app_version = AppVersion(app_arn=self.arn, version_name=None, identifier=0) + self.app_versions.append(app_version) + + def get_version(self, version_name: str) -> "AppVersion": + for v in self.app_versions: + if v.app_version == version_name: + return v + raise AppVersionNotFound def to_json(self) -> Dict[str, Any]: resp = { @@ -55,6 +79,7 @@ class App(BaseModel): "assessmentSchedule": self.assessment_schedule, "complianceStatus": self.compliance_status, "creationTime": self.creation_time, + "driftStatus": "NotChecked", "name": self.name, "resilienceScore": self.resilience_score, "status": self.status, @@ -71,6 +96,54 @@ class App(BaseModel): return resp +class Resource: + def __init__( + self, + logical_resource_id: Dict[str, Any], + physical_resource_id: str, + resource_type: str, + components: List[AppComponent], + ): + self.logical_resource_id = logical_resource_id + self.physical_resource_id = physical_resource_id + self.resource_type = resource_type + self.components = components + + def to_json(self) -> Dict[str, Any]: + return { + "appComponents": [c.to_json() for c in self.components], + "resourceType": self.resource_type, + "logicalResourceId": self.logical_resource_id, + "physicalResourceId": {"identifier": self.physical_resource_id}, + "resourceName": self.logical_resource_id["identifier"], + } + + +class AppVersion(BaseModel): + def __init__(self, app_arn: str, version_name: Optional[str], identifier: int): + self.app_arn = app_arn + self.eks_sources: List[Dict[str, Any]] = [] + self.source_arns: List[str] = [] + self.terraform_sources: List[Dict[str, str]] = [] + self.app_version = "release" if version_name else "draft" + self.identifier = identifier + self.creation_time = unix_time() + self.version_name = version_name + self.app_components: List[AppComponent] = [] + self.status = "Pending" + self.resources: List[Resource] = [] + + def to_json(self) -> Dict[str, Any]: + resp = { + "appVersion": self.app_version, + "creationTime": self.creation_time, + "identifier": self.identifier, + } + if self.version_name: + resp["versionName"] = self.version_name + return resp + + class Policy(BaseModel): def __init__( self, @@ -208,5 +281,88 @@ class ResilienceHubBackend(BaseBackend): def list_tags_for_resource(self, resource_arn: str) -> Dict[str, str]: return self.tagger.get_tag_dict_for_resource(resource_arn) + def import_resources_to_draft_app_version( + self, + app_arn: str, + eks_sources: List[Dict[str, Any]], + source_arns: List[str], + terraform_sources: List[Dict[str, str]], + ) -> AppVersion: + app = self.describe_app(app_arn) + app_version = app.get_version("draft") + + app_version.eks_sources.extend(eks_sources) + app_version.source_arns.extend(source_arns) + app_version.terraform_sources.extend(terraform_sources) + + # Default AppComponent when importing data + # AWS seems to create other components as well, based on the provided sources + app_version.app_components.append( + AppComponent( + _id="appcommon", + name="appcommon", + _type="AWS::ResilienceHub::AppCommonAppComponent", + ) + ) + return app_version + + def create_app_version_app_component( + self, app_arn: str, name: str, _type: str + ) -> AppComponent: + app = self.describe_app(app_arn) + app_version = app.get_version("draft") + component = AppComponent(_id=name, name=name, _type=_type) + app_version.app_components.append(component) + return component + + def list_app_version_app_components( + self, app_arn: str, app_version: str + ) -> List[AppComponent]: + app = self.describe_app(app_arn) + return app.get_version(app_version).app_components + + def create_app_version_resource( + self, + app_arn: str, + app_components: List[str], + logical_resource_id: Dict[str, str], + physical_resource_id: str, + resource_type: str, + ) -> Resource: + app = self.describe_app(app_arn) + app_version = app.get_version("draft") + + components = [c for c in app_version.app_components if c.id in app_components] + + resource = Resource( + logical_resource_id=logical_resource_id, + physical_resource_id=physical_resource_id, + resource_type=resource_type, + components=components, + ) + app_version.resources.append(resource) + return resource + + def list_app_version_resources( + self, app_arn: str, app_version: str + ) -> List[Resource]: + app = self.describe_app(app_arn) + return app.get_version(app_version).resources + + def list_app_versions(self, app_arn: str) -> List[AppVersion]: + app = self.describe_app(app_arn) + return app.app_versions + + def publish_app_version(self, app_arn: str, version_name: str) -> AppVersion: + app = self.describe_app(app_arn) + version = AppVersion( + app_arn=app_arn, version_name=version_name, identifier=len(app.app_versions) + ) + for old_version in app.app_versions: + if old_version.app_version == "release": + old_version.app_version = str(old_version.identifier) + app.app_versions.append(version) + return version + resiliencehub_backends = BackendDict(ResilienceHubBackend, "resiliencehub") diff --git a/moto/resiliencehub/responses.py b/moto/resiliencehub/responses.py index c9b530613..7a8f3b903 100644 --- a/moto/resiliencehub/responses.py +++ b/moto/resiliencehub/responses.py @@ -157,3 +157,99 @@ class ResilienceHubResponse(BaseResponse): resource_arn=resource_arn, ) return json.dumps(dict(tags=tags)) + + def import_resources_to_draft_app_version(self) -> str: + app_arn = self._get_param("appArn") + eks_sources = self._get_param("eksSources") + source_arns = self._get_param("sourceArns") + terraform_sources = self._get_param("terraformSources") + + app_version = self.resiliencehub_backend.import_resources_to_draft_app_version( + app_arn=app_arn, + eks_sources=eks_sources, + source_arns=source_arns, + terraform_sources=terraform_sources, + ) + return json.dumps( + { + "appArn": app_version.app_arn, + "appVersion": app_version.version_name, + "eksSources": eks_sources, + "sourceArns": source_arns, + "status": app_version.status, + "terraformSources": terraform_sources, + } + ) + + def list_app_version_app_components(self) -> str: + app_arn = self._get_param("appArn") + app_version = self._get_param("appVersion") + components = self.resiliencehub_backend.list_app_version_app_components( + app_arn, app_version + ) + return json.dumps( + { + "appArn": app_arn, + "appVersion": app_version, + "appComponents": [c.to_json() for c in components], + } + ) + + def create_app_version_app_component(self) -> str: + app_arn = self._get_param("appArn") + name = self._get_param("name") + _type = self._get_param("type") + component = self.resiliencehub_backend.create_app_version_app_component( + app_arn=app_arn, + name=name, + _type=_type, + ) + return json.dumps( + { + "appArn": app_arn, + "appComponent": component.to_json(), + "appVersion": "draft", + } + ) + + def create_app_version_resource(self) -> str: + app_arn = self._get_param("appArn") + app_components = self._get_param("appComponents") + logical_resource_id = self._get_param("logicalResourceId") + physical_resource_id = self._get_param("physicalResourceId") + resource_type = self._get_param("resourceType") + + resource = self.resiliencehub_backend.create_app_version_resource( + app_arn=app_arn, + app_components=app_components, + logical_resource_id=logical_resource_id, + physical_resource_id=physical_resource_id, + resource_type=resource_type, + ) + + return json.dumps( + { + "appArn": app_arn, + "appVersion": "draft", + "physicalResource": resource.to_json(), + } + ) + + def list_app_version_resources(self) -> str: + app_arn = self._get_param("appArn") + app_version = self._get_param("appVersion") + resources = self.resiliencehub_backend.list_app_version_resources( + app_arn, app_version + ) + return json.dumps({"physicalResources": [r.to_json() for r in resources]}) + + def list_app_versions(self) -> str: + app_arn = self._get_param("appArn") + versions = self.resiliencehub_backend.list_app_versions(app_arn) + return json.dumps({"appVersions": [v.to_json() for v in versions]}) + + def publish_app_version(self) -> str: + app_arn = self._get_param("appArn") + version_name = self._get_param("versionName") + version = self.resiliencehub_backend.publish_app_version(app_arn, version_name) + return json.dumps({"appArn": app_arn, **version.to_json()}) diff --git a/moto/resiliencehub/urls.py b/moto/resiliencehub/urls.py index a39663688..09e96ef08 100644 --- a/moto/resiliencehub/urls.py +++ b/moto/resiliencehub/urls.py @@ -7,12 +7,19 @@ url_bases = [ url_paths = { "{0}/create-app$": ResilienceHubResponse.dispatch, + "{0}/create-app-version-app-component$": ResilienceHubResponse.dispatch, + "{0}/create-app-version-resource$": ResilienceHubResponse.dispatch, "{0}/create-resiliency-policy$": ResilienceHubResponse.dispatch, "{0}/describe-app$": ResilienceHubResponse.dispatch, "{0}/describe-resiliency-policy$": ResilienceHubResponse.dispatch, + "{0}/import-resources-to-draft-app-version$": ResilienceHubResponse.dispatch, "{0}/list-apps$": ResilienceHubResponse.dispatch, "{0}/list-app-assessments$": ResilienceHubResponse.dispatch, + "{0}/list-app-versions$": ResilienceHubResponse.dispatch, + "{0}/list-app-version-app-components$": ResilienceHubResponse.dispatch, + "{0}/list-app-version-resources$": ResilienceHubResponse.dispatch, "{0}/list-resiliency-policies$": ResilienceHubResponse.dispatch, + "{0}/publish-app-version$": ResilienceHubResponse.dispatch, "{0}/tags/.+$": ResilienceHubResponse.dispatch, "{0}/tags/(?P[^/]+)/(?P[^/]+)$": ResilienceHubResponse.method_dispatch( ResilienceHubResponse.tags # type: ignore diff --git a/tests/test_resiliencehub/test_resiliencyhub_resources.py b/tests/test_resiliencehub/test_resiliencyhub_resources.py new file mode 100644 index 000000000..b3d05b23d --- /dev/null +++ b/tests/test_resiliencehub/test_resiliencyhub_resources.py @@ -0,0 +1,162 @@ +import boto3 + +from moto import mock_aws + + +@mock_aws +def test_import_resources_to_draft_app_version(): + client = boto3.client("resiliencehub", region_name="us-east-2") + app_arn = client.create_app(name="myapp")["app"]["appArn"] + + components = client.list_app_version_app_components( + appArn=app_arn, appVersion="draft" + ) + assert components["appArn"] == app_arn + assert components["appComponents"] == [] + assert components["appVersion"] == "draft" + + resp = client.import_resources_to_draft_app_version( + appArn=app_arn, + eksSources=[{"eksClusterArn": "some-arn", "namespaces": ["eks/ns/1"]}], + importStrategy="AddOnly", + sourceArns=["sarn1", "sarn2"], + terraformSources=[{"s3StateFileUrl": "tf://url"}], + ) + assert resp["appArn"] == app_arn + assert resp["eksSources"] == [ + {"eksClusterArn": "some-arn", "namespaces": ["eks/ns/1"]} + ] + assert resp["sourceArns"] == ["sarn1", "sarn2"] + assert resp["status"] == "Pending" + assert resp["terraformSources"] == [{"s3StateFileUrl": "tf://url"}] + + components = client.list_app_version_app_components( + appArn=app_arn, appVersion="draft" + )["appComponents"] + assert { + "id": "appcommon", + "name": "appcommon", + "type": "AWS::ResilienceHub::AppCommonAppComponent", + } in components + + +@mock_aws +def test_create_app_version_app_component(): + client = boto3.client("resiliencehub", region_name="us-east-2") + app_arn = client.create_app(name="myapp")["app"]["appArn"] + component = client.create_app_version_app_component( + appArn=app_arn, + name="my_databases", + type="AWS::ResilienceHub::DatabaseAppComponent", + ) + assert component["appArn"] == app_arn + assert component["appVersion"] == "draft" + assert component["appComponent"]["id"] == "my_databases" + assert component["appComponent"]["name"] == "my_databases" + assert ( + component["appComponent"]["type"] == "AWS::ResilienceHub::DatabaseAppComponent" + ) + + components = client.list_app_version_app_components( + appArn=app_arn, appVersion="draft" + )["appComponents"] + assert components == [ + { + "id": "my_databases", + "name": "my_databases", + "type": "AWS::ResilienceHub::DatabaseAppComponent", + } + ] + + +@mock_aws +def test_create_app_version_resource(): + client = boto3.client("resiliencehub", region_name="us-east-2") + app_arn = client.create_app(name="myapp")["app"]["appArn"] + + component = client.create_app_version_app_component( + appArn=app_arn, + name="my_databases", + type="AWS::ResilienceHub::DatabaseAppComponent", + )["appComponent"] + + resp = client.create_app_version_resource( + appArn=app_arn, + appComponents=["my_databases"], + logicalResourceId={ + "identifier": "myres", + }, + physicalResourceId="myphys", + resourceType="AWS::Lambda::Function", + ) + assert resp["appArn"] == app_arn + assert resp["appVersion"] == "draft" + assert resp["physicalResource"]["appComponents"] == [component] + assert resp["physicalResource"]["logicalResourceId"] == {"identifier": "myres"} + assert resp["physicalResource"]["physicalResourceId"]["identifier"] == "myphys" + assert resp["physicalResource"]["resourceName"] == "myres" + assert resp["physicalResource"]["resourceType"] == "AWS::Lambda::Function" + + resources = client.list_app_version_resources(appArn=app_arn, appVersion="draft") + assert resources["physicalResources"] == [resp["physicalResource"]] + + +@mock_aws +def test_create_app_version_resource_with_unknown_component(): + client = boto3.client("resiliencehub", region_name="us-east-2") + app_arn = client.create_app(name="myapp")["app"]["appArn"] + + # Not sure how AWS behaves, when providing an unknown appComponent + # But let's try to be flexible in what we accept + resp = client.create_app_version_resource( + appArn=app_arn, + appComponents=["unknown"], + logicalResourceId={ + "identifier": "myres", + }, + physicalResourceId="myphys", + resourceType="AWS::Lambda::Function", + ) + assert resp["physicalResource"]["appComponents"] == [] + + +@mock_aws +def test_publish(): + client = boto3.client("resiliencehub", region_name="us-east-2") + app_arn = client.create_app(name="myapp")["app"]["appArn"] + + versions = client.list_app_versions(appArn=app_arn)["appVersions"] + assert len(versions) == 1 + assert versions[0]["appVersion"] == "draft" + assert versions[0]["creationTime"] + assert versions[0]["identifier"] == 0 + + client.import_resources_to_draft_app_version( + appArn=app_arn, + eksSources=[{"eksClusterArn": "some-arn", "namespaces": ["eks/ns/1"]}], + importStrategy="AddOnly", + sourceArns=["sarn1", "sarn2"], + terraformSources=[{"s3StateFileUrl": "tf://url"}], + ) + + publish = client.publish_app_version(appArn=app_arn, versionName="v1") + assert publish["appArn"] == app_arn + assert publish["appVersion"] == "release" + assert publish["identifier"] == 1 + assert publish["versionName"] == "v1" + + versions = client.list_app_versions(appArn=app_arn)["appVersions"] + assert len(versions) == 2 + assert versions[1]["appVersion"] == "release" + assert versions[1]["identifier"] == 1 + assert versions[1]["versionName"] == "v1" + + client.publish_app_version(appArn=app_arn, versionName="v2") + + versions = client.list_app_versions(appArn=app_arn)["appVersions"] + assert len(versions) == 3 + for v in versions: + del v["creationTime"] + + assert {"appVersion": "release", "identifier": 2, "versionName": "v2"} in versions + assert {"appVersion": "1", "identifier": 1, "versionName": "v1"} in versions