Skip to content

Commit 599c140

Browse files
Declarative integration test framework (#40)
Issue #, if available: Create, Read, Update, Delete Integration tests across custom resources scenarios contain test code, fixtures that are repetitive across test files. Description of changes: A declarative integration test framework to write integration test as yaml and a reusable common logic for their execution: * Each test yaml represents an integration test scenario with 1 or many ordered steps. * Each step specifies a verb (apply, patch, delete) and corresponding expectations to assert * Scenarios can run in parallel * Scenarios input yaml can have input replacements (`$SOME_VARIABLE`) which are replaced during test run * Scenario tear down logic is automatic * Scenarios can be marked for inclusion/exclusion with custom markers and usecase markers * Allows custom resource specific customization using `ResourceHelper` auto discovery using `@resource_helper` decorator. * Uses `session` scoped fixture for service bootstrap resources and input replacements, removing the need for call to `service_bootstrap.py` and `service_cleanup.py` from `test-infra` made [here](https://github.com/aws-controllers-k8s/test-infra/blob/main/scripts/run-tests.sh#L84) and [here](https://github.com/aws-controllers-k8s/test-infra/blob/main/scripts/run-tests.sh#L91). Declarative integration test framework (` test/declarative_test_fwk/`) is reusable, this PR is reviewed and merged, it can be moved to `test-infra` later. It can be enhanced further as needed. Refer `test/e2e/declarative_tests.py` file in this PR for using declarative integration test framework. * `test/e2e/scenarios/scenario1.yaml` represent integration test scenario as declarative yaml By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent dac837b commit 599c140

File tree

10 files changed

+933
-2
lines changed

10 files changed

+933
-2
lines changed

test/e2e/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626

2727
bootstrap_directory = Path(__file__).parent
2828
resource_directory = Path(__file__).parent / "resources"
29+
scenarios_directory = Path(__file__).parent / "scenarios"
30+
31+
2932
def load_elasticache_resource(resource_name: str, additional_replacements: Dict[str, Any] = {}):
3033
""" Overrides the default `load_resource_file` to access the specific resources
3134
directory for the current service.

test/e2e/bootstrap_resources.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,25 @@ class TestBootstrapResources:
3434
CWLogGroup2: str
3535
CPGName: str
3636

37+
def replacement_dict(self):
38+
return {
39+
"SNS_TOPIC_ARN": self.SnsTopic1,
40+
"SNS_TOPIC_ARN_2": self.SnsTopic2,
41+
"SG_ID": self.SecurityGroup1,
42+
"SG_ID_2": self.SecurityGroup2,
43+
"USERGROUP_ID": self.UserGroup1,
44+
"USERGROUP_ID_2": self.UserGroup2,
45+
"KMS_KEY_ID": self.KmsKeyID,
46+
"SNAPSHOT_NAME": self.SnapshotName,
47+
"NON_DEFAULT_USER": self.NonDefaultUser,
48+
"LOG_GROUP": self.CWLogGroup1,
49+
"LOG_GROUP_2": self.CWLogGroup2,
50+
"CACHE_PARAMETER_GROUP_NAME": self.CPGName
51+
}
52+
3753
_bootstrap_resources = None
3854

55+
3956
def get_bootstrap_resources(bootstrap_file_name: str = "bootstrap.yaml"):
4057
global _bootstrap_resources
4158
if _bootstrap_resources is None:

test/e2e/conftest.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
# express or implied. See the License for the specific language governing
1212
# permissions and limitations under the License.
1313

14-
import os
1514
import pytest
16-
1715
from acktest import k8s
1816

1917

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
# not use this file except in compliance with the License. A copy of the
5+
# License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is distributed
10+
# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
# express or implied. See the License for the specific language governing
12+
# permissions and limitations under the License.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
# not use this file except in compliance with the License. A copy of the
5+
# License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is distributed
10+
# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
# express or implied. See the License for the specific language governing
12+
# permissions and limitations under the License.
13+
14+
"""Helper for Declarative tests framework for custom resources
15+
"""
16+
17+
from e2e.declarative_test_fwk import model
18+
19+
import logging
20+
from typing import Tuple
21+
from time import sleep
22+
from acktest.k8s import resource as k8s
23+
24+
# holds custom resource helper references
25+
TEST_HELPERS = dict()
26+
27+
28+
def register_resource_helper(resource_kind: str, resource_plural: str):
29+
"""Decorator to discover Custom Resource Helper
30+
31+
Args:
32+
resource_kind: custom resource kind
33+
resource_plural: custom resource kind plural
34+
35+
Returns:
36+
wrapper
37+
"""
38+
39+
def registrar(cls):
40+
global TEST_HELPERS
41+
if issubclass(cls, ResourceHelper):
42+
TEST_HELPERS[resource_kind.lower()] = cls
43+
cls.resource_plural = resource_plural.lower()
44+
logging.info(f"Registered ResourceHelper: {cls.__name__} for custom resource kind: {resource_kind}")
45+
else:
46+
msg = f"Unable to register helper for {resource_kind} resource: {cls} is not a subclass of ResourceHelper"
47+
logging.error(msg)
48+
raise Exception(msg)
49+
return registrar
50+
51+
52+
class ResourceHelper:
53+
"""Provides generic verb (create, patch, delete) methods for custom resources.
54+
Keep its methods stateless. Methods are on instance to allow specialization.
55+
"""
56+
57+
DEFAULT_WAIT_SECS = 30
58+
59+
def create(self, input_data: dict, input_replacements: dict = {}) -> Tuple[k8s.CustomResourceReference, dict]:
60+
"""Creates custom resource inside Kubernetes cluster per the specifications in input data.
61+
62+
Args:
63+
input_data: custom resource details
64+
input_replacements: input replacements
65+
66+
Returns:
67+
k8s.CustomResourceReference, created custom resource
68+
"""
69+
70+
reference = self.custom_resource_reference(input_data, input_replacements)
71+
_ = k8s.create_custom_resource(reference, input_data)
72+
resource = k8s.wait_resource_consumed_by_controller(reference, wait_periods=10)
73+
assert resource is not None
74+
return reference, resource
75+
76+
def patch(self, input_data: dict, input_replacements: dict = {}) -> Tuple[k8s.CustomResourceReference, dict]:
77+
"""Patches custom resource inside Kubernetes cluster per the specifications in input data.
78+
79+
Args:
80+
input_data: custom resource patch details
81+
input_replacements: input replacements
82+
83+
Returns:
84+
k8s.CustomResourceReference, created custom resource
85+
"""
86+
87+
reference = self.custom_resource_reference(input_data, input_replacements)
88+
_ = k8s.patch_custom_resource(reference, input_data)
89+
sleep(self.DEFAULT_WAIT_SECS) # required as controller has likely not placed the resource in modifying
90+
resource = k8s.wait_resource_consumed_by_controller(reference, wait_periods=10)
91+
assert resource is not None
92+
return reference, resource
93+
94+
def delete(self, reference: k8s.CustomResourceReference) -> None:
95+
"""Deletes custom resource inside Kubernetes cluster and waits for delete completion
96+
97+
Args:
98+
reference: custom resource reference
99+
100+
Returns:
101+
None
102+
"""
103+
104+
resource = k8s.get_resource(reference)
105+
if not resource:
106+
logging.warning(f"ResourceReference {reference} not found. Not invoking k8s delete api.")
107+
return
108+
109+
k8s.delete_custom_resource(reference, wait_periods=30, period_length=60) # throws exception if wait fails
110+
sleep(self.DEFAULT_WAIT_SECS)
111+
self.wait_for_delete(reference) # throws exception if wait fails
112+
113+
def assert_expectations(self, verb: str, input_data: dict, expectations: model.ExpectDict, reference: k8s.CustomResourceReference) -> None:
114+
"""Asserts custom resource reference inside Kubernetes cluster against the supplied expectations
115+
116+
:param verb: expectations after performing the verb (apply, patch, delete)
117+
:param input_data: input data to verb
118+
:param expectations: expectations to assert
119+
:param reference: custom resource reference
120+
:return: None
121+
"""
122+
self._assert_conditions(expectations, reference, wait=False)
123+
# conditions expectations met, now check current resource against expectations
124+
resource = k8s.get_resource(reference)
125+
self.assert_items(expectations.get("status"), resource.get("status"))
126+
127+
# self._assert_state(expectations.get("spec"), resource) # uncomment to support spec assertions
128+
129+
def wait_for(self, wait_expectations: dict, reference: k8s.CustomResourceReference) -> None:
130+
"""Waits for custom resource reference details inside Kubernetes cluster to match supplied config,
131+
currently supports wait on "status.conditions",
132+
it can be enhanced later for wait on any/other properties.
133+
134+
Args:
135+
wait_expectations: properties to wait for
136+
reference: custom resource reference
137+
138+
Returns:
139+
None
140+
"""
141+
142+
# wait for conditions
143+
self._assert_conditions(wait_expectations, reference, wait=True)
144+
145+
def _assert_conditions(self, expectations: dict, reference: k8s.CustomResourceReference, wait: bool = True) -> None:
146+
expect_conditions: dict = {}
147+
if "status" in expectations and "conditions" in expectations["status"]:
148+
expect_conditions = expectations["status"]["conditions"]
149+
150+
default_wait_periods = 60
151+
# period_length = 1 will result in condition check every second
152+
default_period_length = 1
153+
for (condition_name, expected_value) in expect_conditions.items():
154+
if type(expected_value) is str:
155+
# Example: ACK.Terminal: "True"
156+
if wait:
157+
assert k8s.wait_on_condition(reference, condition_name, expected_value,
158+
wait_periods=default_wait_periods, period_length=default_period_length)
159+
else:
160+
actual_condition = k8s.get_resource_condition(reference, condition_name)
161+
assert actual_condition is not None
162+
assert expected_value == actual_condition.get("status"), f"Condition status mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}"
163+
164+
elif type(expected_value) is dict:
165+
# Example:
166+
# ACK.ResourceSynced:
167+
# status: "False"
168+
# message: "Expected message ..."
169+
# timeout: 60 # seconds
170+
condition_value = expected_value.get("status")
171+
condition_message = expected_value.get("message")
172+
# default wait 60 seconds
173+
wait_timeout = expected_value.get("timeout", default_wait_periods)
174+
175+
if wait:
176+
assert k8s.wait_on_condition(reference, condition_name, condition_value,
177+
wait_periods=wait_timeout, period_length=default_period_length)
178+
179+
actual_condition = k8s.get_resource_condition(reference,
180+
condition_name)
181+
assert actual_condition is not None
182+
assert condition_value == actual_condition.get("status"), f"Condition status mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}"
183+
if condition_message is not None:
184+
assert condition_message == actual_condition.get("message"), f"Condition message mismatch. Expected condition: {condition_name} - {expected_value} but found {actual_condition}"
185+
186+
else:
187+
raise Exception(f"Condition {condition_name} is provided with invalid value: {expected_value} ")
188+
189+
def assert_items(self, expectations: dict, state: dict) -> None:
190+
"""Asserts state against supplied expectations
191+
Override it as needed for custom verifications
192+
193+
Args:
194+
expectations: dictionary with items (expected) to assert in state
195+
state: dictionary with items (actual)
196+
197+
Returns:
198+
None
199+
"""
200+
201+
if not expectations:
202+
# nothing to assert as there are no expectations
203+
return
204+
if not state:
205+
# there are expectations but no given state to validate
206+
# following assert will fail and assert introspection will provide useful information for debugging
207+
assert expectations == state
208+
209+
for (key, value) in expectations.items():
210+
# conditions are processed separately
211+
if key == "conditions":
212+
continue
213+
assert (key, value) == (key, state.get(key))
214+
215+
def custom_resource_reference(self, input_data: dict, input_replacements: dict = {}) -> k8s.CustomResourceReference:
216+
"""Helper method to provide k8s.CustomResourceReference for supplied input
217+
218+
Args:
219+
input_data: custom resource input data
220+
input_replacements: input replacements
221+
222+
Returns:
223+
k8s.CustomResourceReference
224+
"""
225+
226+
resource_name = input_data.get("metadata").get("name")
227+
crd_group = input_replacements.get("CRD_GROUP")
228+
crd_version = input_replacements.get("CRD_VERSION")
229+
230+
reference = k8s.CustomResourceReference(
231+
crd_group, crd_version, self.resource_plural, resource_name, namespace="default")
232+
return reference
233+
234+
def wait_for_delete(self, reference: k8s.CustomResourceReference) -> None:
235+
"""Override this method to implement custom wail logic on resource delete.
236+
237+
Args:
238+
reference: custom resource reference
239+
240+
Returns:
241+
None
242+
"""
243+
244+
logging.debug(f"No-op wait_for_delete()")
245+
246+
247+
def get_resource_helper(resource_kind: str) -> ResourceHelper:
248+
"""Provides ResourceHelper for supplied custom resource kind
249+
If no helper is registered for the supplied resource kind then returns default ResourceHelper
250+
251+
Args:
252+
resource_kind: custom resource kind string
253+
254+
Returns:
255+
custom resource helper instance
256+
"""
257+
258+
helper_cls = TEST_HELPERS.get(resource_kind.lower())
259+
if helper_cls:
260+
return helper_cls()
261+
return ResourceHelper()

0 commit comments

Comments
 (0)