diff --git a/test/e2e/__init__.py b/test/e2e/__init__.py index eb244167..ffbb4efe 100644 --- a/test/e2e/__init__.py +++ b/test/e2e/__init__.py @@ -26,6 +26,9 @@ bootstrap_directory = Path(__file__).parent resource_directory = Path(__file__).parent / "resources" +scenarios_directory = Path(__file__).parent / "scenarios" + + def load_elasticache_resource(resource_name: str, additional_replacements: Dict[str, Any] = {}): """ Overrides the default `load_resource_file` to access the specific resources directory for the current service. diff --git a/test/e2e/bootstrap_resources.py b/test/e2e/bootstrap_resources.py index e5e2ef4e..205b8fd9 100644 --- a/test/e2e/bootstrap_resources.py +++ b/test/e2e/bootstrap_resources.py @@ -34,8 +34,25 @@ class TestBootstrapResources: CWLogGroup2: str CPGName: str + def replacement_dict(self): + return { + "SNS_TOPIC_ARN": self.SnsTopic1, + "SNS_TOPIC_ARN_2": self.SnsTopic2, + "SG_ID": self.SecurityGroup1, + "SG_ID_2": self.SecurityGroup2, + "USERGROUP_ID": self.UserGroup1, + "USERGROUP_ID_2": self.UserGroup2, + "KMS_KEY_ID": self.KmsKeyID, + "SNAPSHOT_NAME": self.SnapshotName, + "NON_DEFAULT_USER": self.NonDefaultUser, + "LOG_GROUP": self.CWLogGroup1, + "LOG_GROUP_2": self.CWLogGroup2, + "CACHE_PARAMETER_GROUP_NAME": self.CPGName + } + _bootstrap_resources = None + def get_bootstrap_resources(bootstrap_file_name: str = "bootstrap.yaml"): global _bootstrap_resources if _bootstrap_resources is None: diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index b05fd51e..8a1ce7ab 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -11,9 +11,7 @@ # express or implied. See the License for the specific language governing # permissions and limitations under the License. -import os import pytest - from acktest import k8s diff --git a/test/e2e/declarative_test_fwk/__init__.py b/test/e2e/declarative_test_fwk/__init__.py new file mode 100644 index 00000000..5d489e5d --- /dev/null +++ b/test/e2e/declarative_test_fwk/__init__.py @@ -0,0 +1,12 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. diff --git a/test/e2e/declarative_test_fwk/helper.py b/test/e2e/declarative_test_fwk/helper.py new file mode 100644 index 00000000..7705f38e --- /dev/null +++ b/test/e2e/declarative_test_fwk/helper.py @@ -0,0 +1,261 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +"""Helper for Declarative tests framework for custom resources +""" + +from e2e.declarative_test_fwk import model + +import logging +from typing import Tuple +from time import sleep +from acktest.k8s import resource as k8s + +# holds custom resource helper references +TEST_HELPERS = dict() + + +def register_resource_helper(resource_kind: str, resource_plural: str): + """Decorator to discover Custom Resource Helper + + Args: + resource_kind: custom resource kind + resource_plural: custom resource kind plural + + Returns: + wrapper + """ + + def registrar(cls): + global TEST_HELPERS + if issubclass(cls, ResourceHelper): + TEST_HELPERS[resource_kind.lower()] = cls + cls.resource_plural = resource_plural.lower() + logging.info(f"Registered ResourceHelper: {cls.__name__} for custom resource kind: {resource_kind}") + else: + msg = f"Unable to register helper for {resource_kind} resource: {cls} is not a subclass of ResourceHelper" + logging.error(msg) + raise Exception(msg) + return registrar + + +class ResourceHelper: + """Provides generic verb (create, patch, delete) methods for custom resources. + Keep its methods stateless. Methods are on instance to allow specialization. + """ + + DEFAULT_WAIT_SECS = 30 + + def create(self, input_data: dict, input_replacements: dict = {}) -> Tuple[k8s.CustomResourceReference, dict]: + """Creates custom resource inside Kubernetes cluster per the specifications in input data. + + Args: + input_data: custom resource details + input_replacements: input replacements + + Returns: + k8s.CustomResourceReference, created custom resource + """ + + reference = self.custom_resource_reference(input_data, input_replacements) + _ = k8s.create_custom_resource(reference, input_data) + resource = k8s.wait_resource_consumed_by_controller(reference, wait_periods=10) + assert resource is not None + return reference, resource + + def patch(self, input_data: dict, input_replacements: dict = {}) -> Tuple[k8s.CustomResourceReference, dict]: + """Patches custom resource inside Kubernetes cluster per the specifications in input data. + + Args: + input_data: custom resource patch details + input_replacements: input replacements + + Returns: + k8s.CustomResourceReference, created custom resource + """ + + reference = self.custom_resource_reference(input_data, input_replacements) + _ = k8s.patch_custom_resource(reference, input_data) + sleep(self.DEFAULT_WAIT_SECS) # required as controller has likely not placed the resource in modifying + resource = k8s.wait_resource_consumed_by_controller(reference, wait_periods=10) + assert resource is not None + return reference, resource + + def delete(self, reference: k8s.CustomResourceReference) -> None: + """Deletes custom resource inside Kubernetes cluster and waits for delete completion + + Args: + reference: custom resource reference + + Returns: + None + """ + + resource = k8s.get_resource(reference) + if not resource: + logging.warning(f"ResourceReference {reference} not found. Not invoking k8s delete api.") + return + + k8s.delete_custom_resource(reference, wait_periods=30, period_length=60) # throws exception if wait fails + sleep(self.DEFAULT_WAIT_SECS) + self.wait_for_delete(reference) # throws exception if wait fails + + def assert_expectations(self, verb: str, input_data: dict, expectations: model.ExpectDict, reference: k8s.CustomResourceReference) -> None: + """Asserts custom resource reference inside Kubernetes cluster against the supplied expectations + + :param verb: expectations after performing the verb (apply, patch, delete) + :param input_data: input data to verb + :param expectations: expectations to assert + :param reference: custom resource reference + :return: None + """ + self._assert_conditions(expectations, reference, wait=False) + # conditions expectations met, now check current resource against expectations + resource = k8s.get_resource(reference) + self.assert_items(expectations.get("status"), resource.get("status")) + + # self._assert_state(expectations.get("spec"), resource) # uncomment to support spec assertions + + def wait_for(self, wait_expectations: dict, reference: k8s.CustomResourceReference) -> None: + """Waits for custom resource reference details inside Kubernetes cluster to match supplied config, + currently supports wait on "status.conditions", + it can be enhanced later for wait on any/other properties. + + Args: + wait_expectations: properties to wait for + reference: custom resource reference + + Returns: + None + """ + + # wait for conditions + self._assert_conditions(wait_expectations, reference, wait=True) + + def _assert_conditions(self, expectations: dict, reference: k8s.CustomResourceReference, wait: bool = True) -> None: + expect_conditions: dict = {} + if "status" in expectations and "conditions" in expectations["status"]: + expect_conditions = expectations["status"]["conditions"] + + default_wait_periods = 60 + # period_length = 1 will result in condition check every second + default_period_length = 1 + for (condition_name, expected_value) in expect_conditions.items(): + if type(expected_value) is str: + # Example: ACK.Terminal: "True" + if wait: + assert k8s.wait_on_condition(reference, condition_name, expected_value, + wait_periods=default_wait_periods, period_length=default_period_length) + else: + actual_condition = k8s.get_resource_condition(reference, condition_name) + assert actual_condition is not None + assert expected_value == actual_condition.get("status"), f"Condition status mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}" + + elif type(expected_value) is dict: + # Example: + # ACK.ResourceSynced: + # status: "False" + # message: "Expected message ..." + # timeout: 60 # seconds + condition_value = expected_value.get("status") + condition_message = expected_value.get("message") + # default wait 60 seconds + wait_timeout = expected_value.get("timeout", default_wait_periods) + + if wait: + assert k8s.wait_on_condition(reference, condition_name, condition_value, + wait_periods=wait_timeout, period_length=default_period_length) + + actual_condition = k8s.get_resource_condition(reference, + condition_name) + assert actual_condition is not None + assert condition_value == actual_condition.get("status"), f"Condition status mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}" + if condition_message is not None: + assert condition_message == actual_condition.get("message"), f"Condition message mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}" + + else: + raise Exception(f"Condition {condition_name} is provided with invalid value: {expected_value} ") + + def assert_items(self, expectations: dict, state: dict) -> None: + """Asserts state against supplied expectations + Override it as needed for custom verifications + + Args: + expectations: dictionary with items (expected) to assert in state + state: dictionary with items (actual) + + Returns: + None + """ + + if not expectations: + # nothing to assert as there are no expectations + return + if not state: + # there are expectations but no given state to validate + # following assert will fail and assert introspection will provide useful information for debugging + assert expectations == state + + for (key, value) in expectations.items(): + # conditions are processed separately + if key == "conditions": + continue + assert (key, value) == (key, state.get(key)) + + def custom_resource_reference(self, input_data: dict, input_replacements: dict = {}) -> k8s.CustomResourceReference: + """Helper method to provide k8s.CustomResourceReference for supplied input + + Args: + input_data: custom resource input data + input_replacements: input replacements + + Returns: + k8s.CustomResourceReference + """ + + resource_name = input_data.get("metadata").get("name") + crd_group = input_replacements.get("CRD_GROUP") + crd_version = input_replacements.get("CRD_VERSION") + + reference = k8s.CustomResourceReference( + crd_group, crd_version, self.resource_plural, resource_name, namespace="default") + return reference + + def wait_for_delete(self, reference: k8s.CustomResourceReference) -> None: + """Override this method to implement custom wail logic on resource delete. + + Args: + reference: custom resource reference + + Returns: + None + """ + + logging.debug(f"No-op wait_for_delete()") + + +def get_resource_helper(resource_kind: str) -> ResourceHelper: + """Provides ResourceHelper for supplied custom resource kind + If no helper is registered for the supplied resource kind then returns default ResourceHelper + + Args: + resource_kind: custom resource kind string + + Returns: + custom resource helper instance + """ + + helper_cls = TEST_HELPERS.get(resource_kind.lower()) + if helper_cls: + return helper_cls() + return ResourceHelper() diff --git a/test/e2e/declarative_test_fwk/loader.py b/test/e2e/declarative_test_fwk/loader.py new file mode 100644 index 00000000..e6fd3cd2 --- /dev/null +++ b/test/e2e/declarative_test_fwk/loader.py @@ -0,0 +1,99 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +"""Test Scenarios loader for Declarative tests framework for custom resources +""" + +from e2e.declarative_test_fwk import model +import pytest +import os +from typing import Iterable, List +from pathlib import Path +from os.path import isfile, join +from acktest.resources import load_resource_file, random_suffix_name + + +def list_scenarios(scenarios_directory: Path) -> Iterable: + """Lists test scenarios from given directory + + Args: + scenarios_directory: directory containing scenarios yaml files + + Returns: + Iterable scenarios for pytest parameterized fixture + """ + + scenarios_list = [] + for scenario_file in sorted(os.listdir(scenarios_directory)): + scenario_file_full_path = join(scenarios_directory, scenario_file) + if not isfile(scenario_file_full_path) or not scenario_file.endswith(".yaml"): + continue + scenarios_list.append(pytest.param(Path(scenario_file_full_path), marks=marks(Path(scenario_file_full_path)))) + return scenarios_list + + +def load_scenario(scenario_file: Path, resource_directory: Path = None, replacements: dict = {}) -> model.Scenario: + """Loads scenario from given scenario_file + + Args: + scenario_file: yaml file containing scenario + resource_directory: Path to custom resources directory + replacements: input replacements + + Returns: + Scenario reference + """ + + scenario_name = scenario_file.stem + replacements = replacements.copy() + replacements["RANDOM_SUFFIX"] = random_suffix_name("", 16) + scenario = model.Scenario(resource_directory, load_resource_file( + scenario_file.parent, scenario_name, additional_replacements=replacements), replacements) + return scenario + + +def idfn(scenario_file_full_path: Path) -> str: + """Provides scenario file name as scenario test id + + Args: + scenario_file_full_path: test scenario file path + + Returns: + scenario test id string + """ + + return scenario_file_full_path.name + + +def marks(scenario_file_path: Path) -> List: + """Provides pytest markers for the given scenario + + Args: + scenario_file_path: test scenario file path + + Returns: + pytest markers for the scenario + """ + + scenario_config = load_resource_file( + scenario_file_path.parent, scenario_file_path.stem) + + markers = [] + for mark in scenario_config.get("marks", []): + if mark == "canary": + markers.append(pytest.mark.canary) + elif mark == "slow": + markers.append(pytest.mark.slow) + elif mark == "blocked": + markers.append(pytest.mark.blocked) + return markers diff --git a/test/e2e/declarative_test_fwk/model.py b/test/e2e/declarative_test_fwk/model.py new file mode 100644 index 00000000..9940edeb --- /dev/null +++ b/test/e2e/declarative_test_fwk/model.py @@ -0,0 +1,187 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +"""Model for Declarative tests framework for custom resources +""" + + +from enum import Enum, auto +from typing import TypedDict, Dict, Union, List +from pathlib import Path +from os.path import join +from acktest.resources import load_resource_file + + +class Verb(Enum): + """ + Verb for custom resource in a test step. + """ + create = auto() + patch = auto() + delete = auto() + + +# fields for 'resource' field in a test Scenario +class ResourceDict(TypedDict, total=False): + apiVersion: str + kind: str + metadata: Dict + + +# fields for 'create' Verb in a test step +class CreateDict(ResourceDict): + spec: Dict + + +# fields for 'patch' Verb in a test step +class PatchDict(ResourceDict): + spec: Dict + + +# fields for 'delete' Verb in a test step +class DeleteDict(ResourceDict): + pass + + +# fields for 'expect' field in a test step +class ExpectDict(TypedDict, total=False): + spec: Dict + status: Dict + + +# fields in a test step +class StepDict(TypedDict, total=False): + id: str + description: str + create: Union[str, CreateDict] + patch: Union[str, PatchDict] + delete: Union[str, DeleteDict] + wait: Union[int, Dict] + expect: ExpectDict + + +class Step: + """ + Represents a declarative test step + """ + + def __init__(self, resource_directory: Path, config: StepDict, custom_resource_details: dict, replacements: dict = {}): + self.config = config + self.custom_resource_details = custom_resource_details + self.replacements = replacements + + self.verb = None + self.input_data = {} + self.expectations: ExpectDict = None + + # (k8s.CustomResourceReference, ko) to teardown + self.teardown_list = [] + + # validate: only one verb per step + step_verb = None + for verb in list(Verb): + if verb.name in self.config: + if not step_verb: + step_verb = verb + else: + raise ValueError(f"Multiple verbs specified for step: {self.id}." + f" Please specify only one verb from" + f" supported verbs: { {verb.name for verb in list(Verb)} }.") + + # a step with no verb can be used to assert preconditions + # thus, verb is optional. + if step_verb: + self.verb = step_verb + self.input_data = self.config.get(step_verb.name) + if type(self.input_data) is str: + if self.input_data.endswith(".yaml"): + # load input data from resource file + resource_file_name = self.input_data + resource_file_path = Path(join(resource_directory, resource_file_name)) + self.input_data = load_resource_file( + resource_file_path.parent, resource_file_path.stem, additional_replacements=replacements) + else: + # consider the input as resource name string + # confirm that self.custom_resource_details must be provided with same name + if self.custom_resource_details["metadata"]["name"] != self.input_data: + raise ValueError(f"Unable to determine input data for '{self.verb}' at step: {self.id}") + # self.custom_resource_details will be mixed in into self.input_data + self.input_data = {} + + if len(self.input_data) == 0 and not self.custom_resource_details: + raise ValueError(f"Unable to determine custom resource at step: {self.id}") + + if self.custom_resource_details: + self.input_data = {**self.custom_resource_details, **self.input_data} + + self.wait = self.config.get("wait") + self.expectations = self.config.get("expect") + + @property + def id(self) -> str: + return self.config.get("id", "") + + @property + def description(self) -> str: + return self.config.get("description", "") + + @property + def resource_kind(self) -> str: + return self.input_data.get("kind") + + def __str__(self) -> str: + return f"Step(id='{self.id}')" + + def __repr__(self) -> str: + return str(self) + + +# fields in a test scenario +class ScenarioDict(TypedDict, total=False): + id: str + description: str + marks: List[str] + resource: ResourceDict + steps: List[StepDict] + + +class Scenario: + """ + Represents a declarative test scenario with steps + """ + + def __init__(self, resource_directory: Path, config: ScenarioDict, replacements: dict = {}): + self.config = config + self.test_steps = [] + self.replacements = replacements + custom_resource_details = self.config.get("resource", {}) + for step_config in self.config.get("steps", []): + self.test_steps.append(Step(resource_directory, step_config, custom_resource_details.copy(), replacements)) + + @property + def id(self) -> str: + return self.config.get("id", "") + + @property + def description(self) -> str: + return self.config.get("description", "") + + @property + def steps(self): + return self.test_steps + + def __str__(self) -> str: + return f"Scenario(id='{self.id}')" + + def __repr__(self) -> str: + return str(self) diff --git a/test/e2e/declarative_test_fwk/runner.py b/test/e2e/declarative_test_fwk/runner.py new file mode 100644 index 00000000..4492c13e --- /dev/null +++ b/test/e2e/declarative_test_fwk/runner.py @@ -0,0 +1,228 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +"""Runner for Declarative tests framework scenarios for custom resources +""" + +from e2e.declarative_test_fwk import model, helper +import pytest +import sys +import logging +from time import sleep +from acktest.k8s import resource as k8s + + +def run(scenario: model.Scenario) -> None: + """Runs steps in the given scenario + + Args: + scenario: the scenario to run + + Returns: + None + """ + + logging.info(f"Execute: {scenario}") + for step in scenario.steps: + run_step(step) + + +def teardown(scenario: model.Scenario) -> None: + """Teardown steps in the given scenario in reverse run order + + Args: + scenario: the scenario to teardown + + Returns: + None + """ + + logging.info(f"Teardown: {scenario}") + teardown_failures = [] + # tear down steps in reverse order + for step in reversed(scenario.steps): + try: + teardown_step(step) + except: + error = f"Failed to teardown: {step}. " \ + f"Unexpected error: {sys.exc_info()[0]}" + teardown_failures.append(error) + + if len(teardown_failures) != 0: + teardown_failures.insert(0, f"Failures during teardown: {scenario}") + failures = "\n\t- ".join(teardown_failures) + logging.error(failures) + pytest.fail(failures) + + +def run_step(step: model.Step) -> None: + """Runs a test scenario step + + Args: + step: the step to run + + Returns: + None + """ + + logging.info(f"Execute: {step}") + if step.verb == model.Verb.create: + create_resource(step) + elif step.verb == model.Verb.patch: + patch_resource(step) + elif step.verb == model.Verb.delete: + delete_resource(step) + wait(step) + assert_expectations(step) + + +def create_resource(step: model.Step) -> None: + """Perform the Verb "create" for given test step. + It results in creating custom resource inside Kubernetes cluster per the specification from the step. + + Args: + step: test step + + Returns: + None + """ + + logging.debug(f"create: {step}") + if not step.input_data: + return + resource_helper = helper.get_resource_helper(step.resource_kind) + (reference, ko) = resource_helper.create(step.input_data, step.replacements) + # track created reference to teardown later + step.teardown_list.append((reference, ko)) + + +def patch_resource(step: model.Step) -> None: + """Perform the Verb "patch" for given test step. + It results in patching custom resource inside Kubernetes cluster per the specification from the step. + + Args: + step: test step + + Returns: + None + """ + + logging.debug(f"patch: {step}") + if not step.input_data: + return + + resource_helper = helper.get_resource_helper(step.resource_kind) + (reference, ko) = resource_helper.patch(step.input_data, step.replacements) + # no need to teardown patched reference, its creator should tear it down. + + +def delete_resource(step: model.Step, reference: k8s.CustomResourceReference = None) -> None: + """Perform the Verb "delete" for given custom resource reference in given test step. + It results in deleting the custom resource inside Kubernetes cluster. + + Args: + step: test step + reference: custom resource reference to delete + + Returns: + None + """ + + resource_helper = helper.get_resource_helper(step.resource_kind) + if not reference: + logging.debug(f"delete: {step}") + reference = resource_helper.custom_resource_reference(step.input_data, step.replacements) + if k8s.get_resource_exists(reference): + logging.debug(f"deleting resource: {reference}") + resource_helper.delete(reference) + else: + logging.info(f"Resource already deleted: {reference}") + + +def wait(step: model.Step) -> None: + """Performs wait logic for the given step. + The step provides the wait details (properties/conditions values to wait for) + + Args: + step: test step + + Returns: + None + """ + + logging.debug(f"wait: {step}") + if not step.wait: + return + + if type(step.wait) is int: + interval_seconds = step.wait + logging.debug(f"Going to sleep for {interval_seconds} seconds during step {step}") + sleep(interval_seconds) + return + + resource_helper = helper.get_resource_helper(step.resource_kind) + reference = resource_helper.custom_resource_reference(step.input_data, step.replacements) + try: + resource_helper.wait_for(step.wait, reference) + except AssertionError as ae: + logging.error(f"Wait failed, AssertionError at {step}") + raise ae + except Exception as e: + logging.error(f"Wait failed, Exception at {step}") + raise e + + +def assert_expectations(step: model.Step) -> None: + """Asserts expectations as specified in the Step. + + Args: + step: test step + + Returns: + None + """ + + logging.info(f"assert: {step}") + if not step.expectations: + return + + resource_helper = helper.get_resource_helper(step.resource_kind) + reference = resource_helper.custom_resource_reference(step.input_data, step.replacements) + try: + resource_helper.assert_expectations(step.verb, step.input_data, step.expectations, reference) + except AssertionError as ae: + logging.error(f"AssertionError at {step}") + raise ae + + +def teardown_step(step: model.Step) -> None: + """Teardown custom resources that were created during step execution (run) inside Kubernetes cluster. + + Args: + step: test step + + Returns: + None + """ + + if not step or len(step.teardown_list) == 0: + return + + logging.info(f"teardown: {step}") + + for (reference, _) in step.teardown_list: + if reference: + delete_resource(step, reference) + + # clear list + step.teardown_list = [] diff --git a/test/e2e/scenarios/cmd_basic_create_update.yaml b/test/e2e/scenarios/cmd_basic_create_update.yaml new file mode 100644 index 00000000..aed144c9 --- /dev/null +++ b/test/e2e/scenarios/cmd_basic_create_update.yaml @@ -0,0 +1,45 @@ +id: "RG_CMD_basic_create_update" +description: "In this test we create RG with absolutely minimum configuration and try to update it." +#marks: +# - slow +# - blocked +resource: + apiVersion: $CRD_GROUP/$CRD_VERSION + kind: ReplicationGroup + metadata: + name: test$RANDOM_SUFFIX +steps: + - id: "create_CMD_replication_group" + description: "Initial config" + create: + spec: + engine: redis + replicationGroupID: test$RANDOM_SUFFIX + replicationGroupDescription: Basic create and update of CMD RG + cacheNodeType: cache.t3.micro + numNodeGroups: 1 + wait: + status: + conditions: + ACK.ResourceSynced: + status: "True" + timeout: 1800 + expect: + status: + status: "available" + - id: "invalid_cacheNodeType_on_CMD_replication_group_causes_Terminal_condition" + description: "Negative test include transitEncryptionEnabled" + patch: + spec: + cacheNodeType: cache.micro # invalid value + wait: 60 + expect: + status: + conditions: + ACK.Terminal: "True" + ACK.ResourceSynced: + status: "True" + # message: "Expected message ..." + - id: "delete_CMD_RG" + description: "Delete cluster mode disabled replication group" + delete: test$RANDOM_SUFFIX diff --git a/test/e2e/tests/test_scenarios.py b/test/e2e/tests/test_scenarios.py new file mode 100644 index 00000000..bbaf37c4 --- /dev/null +++ b/test/e2e/tests/test_scenarios.py @@ -0,0 +1,81 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You may +# not use this file except in compliance with the License. A copy of the +# License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is distributed +# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +# express or implied. See the License for the specific language governing +# permissions and limitations under the License. + +""" +Tests for custom resources. +Uses declarative tests framework for custom resources. + +To add test: add scenario yaml to scenarios/ directory. +""" + +from e2e.declarative_test_fwk import runner, loader, helper + +import pytest +import boto3 +import logging + +from e2e import service_marker, scenarios_directory, resource_directory, CRD_VERSION, CRD_GROUP, SERVICE_NAME +from e2e.bootstrap_resources import get_bootstrap_resources + +from acktest.k8s import resource as k8s + + +@helper.register_resource_helper(resource_kind="ReplicationGroup", resource_plural="ReplicationGroups") +class ReplicationGroupHelper(helper.ResourceHelper): + """ + Helper for replication group scenarios. + Overrides methods as required for custom resources. + """ + + def wait_for_delete(self, reference: k8s.CustomResourceReference): + logging.debug(f"ReplicationGroupHelper - wait_for_delete()") + ec = boto3.client("elasticache") + waiter = ec.get_waiter('replication_group_deleted') + # throws exception if wait fails + waiter.wait(ReplicationGroupId=reference.name) + + +@pytest.fixture(scope="session") +def input_replacements(): + """ + provides input replacements for test scenarios. + """ + resource_replacements = get_bootstrap_resources().replacement_dict() + replacements = { + "CRD_VERSION": CRD_VERSION, + "CRD_GROUP": CRD_GROUP, + "SERVICE_NAME": SERVICE_NAME + } + yield {**resource_replacements, **replacements} + + +@pytest.fixture(params=loader.list_scenarios(scenarios_directory), ids=loader.idfn) +def scenario(request, input_replacements): + """ + Parameterized pytest fixture + Provides test scenarios to execute + Supports parallel execution of test scenarios + """ + scenario_file_path = request.param + scenario = loader.load_scenario(scenario_file_path, resource_directory, input_replacements) + yield scenario + runner.teardown(scenario) + + +@service_marker +class TestScenarios: + """ + Declarative scenarios based test suite + """ + def test_scenario(self, scenario): + runner.run(scenario)