Skip to content

Commit a9c8f15

Browse files
Add smoke test framework for opensearch bundle (opensearch-project#5185)
Signed-off-by: Zelin Hao <[email protected]> Signed-off-by: Peter Zhu <[email protected]> Co-authored-by: Peter Zhu <[email protected]>
1 parent c79913e commit a9c8f15

22 files changed

+61537
-68
lines changed

Pipfile

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ types-PyYAML = "~=6.0.1"
99
# TODO: The 'requests' package stays on 2.28 until we deprecate CentOS7.
1010
# As newer version requires openssl1.1.1 where CentOS7 only provides openssl1.1.0.
1111
# https://github.com/opensearch-project/opensearch-build/issues/3554
12-
requests = "<=2.28.1"
12+
requests = "==2.31.0"
1313
types-requests = "~=2.25"
1414
pre-commit = "~=2.15.0"
1515
isort = "~=5.9"
@@ -44,6 +44,7 @@ types-urllib3 = "~=1.26.25.14"
4444
charset-normalizer = "~=2.1.1"
4545
beautifulsoup4 = "~=4.12.3"
4646
lxml = "~=5.3.0"
47+
openapi-core = "~=0.19.4"
4748

4849
[dev-packages]
4950

Pipfile.lock

+377-66
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

manifests/2.19.0/opensearch-2.19.0-test.yml

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ ci:
66
name: opensearchstaging/ci-runner:ci-runner-al2-opensearch-build-v1
77
args: -e JAVA_HOME=/opt/java/openjdk-21
88
components:
9+
- name: opensearch
10+
smoke-test:
11+
test-spec: opensearch.yml
912
- name: alerting
1013
integ-test:
1114
test-configs:

src/run_smoke_test.py

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env python
2+
# Copyright OpenSearch Contributors
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# The OpenSearch Contributors require contributions made to
6+
# this file be licensed under the Apache-2.0 license or a
7+
# compatible open source license.
8+
9+
import sys
10+
11+
from manifests.test_manifest import TestManifest
12+
from system import console
13+
from test_workflow.smoke_test.smoke_test_runners import SmokeTestRunners
14+
from test_workflow.test_args import TestArgs
15+
16+
17+
def main() -> int:
18+
args = TestArgs()
19+
20+
# Any logging.info call preceding to next line in the execution chain will make the console output not displaying logs in console.
21+
console.configure(level=args.logging_level)
22+
23+
test_manifest = TestManifest.from_path(args.test_manifest_path)
24+
25+
all_results = SmokeTestRunners.from_test_manifest(args, test_manifest).run()
26+
27+
all_results.log()
28+
29+
if all_results.failed():
30+
return 1
31+
else:
32+
return 0
33+
34+
35+
if __name__ == "__main__":
36+
sys.exit(main())
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright OpenSearch Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OpenSearch Contributors require contributions made to
5+
# this file be licensed under the Apache-2.0 license or a
6+
# compatible open source license.
7+
#
8+
# This page intentionally left blank.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright OpenSearch Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OpenSearch Contributors require contributions made to
5+
# this file be licensed under the Apache-2.0 license or a
6+
# compatible open source license.
7+
8+
import logging
9+
import os
10+
import shutil
11+
import time
12+
13+
import requests
14+
15+
from git.git_repository import GitRepository
16+
from manifests.build_manifest import BuildManifest
17+
from manifests.bundle_manifest import BundleManifest
18+
from manifests.test_manifest import TestManifest
19+
from system.process import Process
20+
from test_workflow.integ_test.distributions import Distributions
21+
from test_workflow.test_args import TestArgs
22+
from test_workflow.test_recorder.test_recorder import TestRecorder
23+
24+
25+
class SmokeTestClusterOpenSearch():
26+
# dependency_installer: DependencyInstallerOpenSearch
27+
repo: GitRepository
28+
29+
def __init__(
30+
self,
31+
args: TestArgs,
32+
work_dir: str,
33+
test_recorder: TestRecorder
34+
) -> None:
35+
self.args = args
36+
self.work_dir = work_dir
37+
self.test_recorder = test_recorder
38+
self.process_handler = Process()
39+
self.test_manifest = TestManifest.from_path(args.test_manifest_path)
40+
self.product = self.test_manifest.name.lower().replace(" ", "-")
41+
self.path = args.paths.get(self.product)
42+
self.build_manifest = BuildManifest.from_urlpath(os.path.join(self.path, "builds", f"{self.product}", "manifest.yml"))
43+
self.bundle_manifest = BundleManifest.from_urlpath(os.path.join(self.path, "dist", f"{self.product}", "manifest.yml"))
44+
self.version = self.bundle_manifest.build.version
45+
self.platform = self.bundle_manifest.build.platform
46+
self.arch = self.bundle_manifest.build.architecture
47+
self.dist = self.bundle_manifest.build.distribution
48+
self.distribution = Distributions.get_distribution(self.product, self.dist, self.version, work_dir)
49+
50+
def cluster_version(self) -> str:
51+
return self.version
52+
53+
def download_or_copy_bundle(self, work_dir: str) -> str:
54+
extension = "tar.gz" if self.dist == "tar" else self.dist
55+
artifact_name = f"{self.product}-{self.version}-{self.platform}-{self.arch}.{extension}"
56+
src_path = '/'.join([self.path.rstrip("/"), "dist", f"{self.product}", f"{artifact_name}"]) \
57+
if self.path.startswith("https://") else os.path.join(self.path, "dist",
58+
f"{self.product}", f"{artifact_name}")
59+
dest_path = os.path.join(work_dir, artifact_name)
60+
61+
if src_path.startswith("https://"):
62+
logging.info(f"Downloading artifacts to {dest_path}")
63+
response = requests.get(src_path)
64+
with open(dest_path, "wb") as file:
65+
file.write(response.content)
66+
else:
67+
logging.info(f"Trying to copy {src_path} to {dest_path}")
68+
# Only copy if it's a file
69+
if os.path.isfile(src_path):
70+
shutil.copy2(src_path, dest_path)
71+
logging.info(f"Copied {src_path} to {dest_path}")
72+
return artifact_name
73+
74+
# Reason we don't re-use test-suite from integ-test is that it's too specific and not generic and lightweight.
75+
def __installation__(self, work_dir: str) -> None:
76+
self.distribution.install(self.download_or_copy_bundle(work_dir))
77+
logging.info("Cluster is installed and ready to be start.")
78+
79+
# Start the cluster after installed and provide endpoint.
80+
def __start_cluster__(self, work_dir: str) -> None:
81+
self.__installation__(work_dir)
82+
self.process_handler.start(self.distribution.start_cmd, self.distribution.install_dir, self.distribution.require_sudo)
83+
logging.info(f"Started OpenSearch with parent PID {self.process_handler.pid}")
84+
time.sleep(30)
85+
logging.info("Cluster is started.")
86+
87+
# Check if the cluster is ready
88+
def __check_cluster_ready__(self) -> bool:
89+
url = "https://localhost:9200/"
90+
logging.info(f"Pinging {url}")
91+
try:
92+
request = requests.get(url, verify=False, auth=("admin", "myStrongPassword123!"))
93+
logging.info(f"Cluster response is {request.text}")
94+
return 200 <= request.status_code < 300
95+
except requests.RequestException as e:
96+
logging.info(f"Request is {request.text}")
97+
logging.info(f"Cluster check fails: {e}")
98+
return False
99+
100+
def __uninstall__(self) -> None:
101+
self.process_handler.terminate()
102+
logging.info("Cluster is terminated.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright OpenSearch Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OpenSearch Contributors require contributions made to
5+
# this file be licensed under the Apache-2.0 license or a
6+
# compatible open source license.
7+
8+
import abc
9+
import json
10+
import logging
11+
import os
12+
import sys
13+
import time
14+
from pathlib import Path
15+
from typing import Any
16+
17+
import yaml
18+
19+
from manifests.component_manifest import Components
20+
from manifests.test_manifest import TestManifest
21+
from system.temporary_directory import TemporaryDirectory
22+
from test_workflow.smoke_test.smoke_test_cluster_opensearch import SmokeTestClusterOpenSearch
23+
from test_workflow.test_args import TestArgs
24+
from test_workflow.test_recorder.test_recorder import TestRecorder
25+
26+
27+
class SmokeTestRunner(abc.ABC):
28+
args: TestArgs
29+
test_manifest: TestManifest
30+
tests_dir: str
31+
test_recorder: TestRecorder
32+
components: Components
33+
34+
def __init__(self, args: TestArgs, test_manifest: TestManifest) -> None:
35+
self.args = args
36+
self.test_manifest = test_manifest
37+
self.tests_dir = os.path.join(os.getcwd(), "test-results")
38+
os.makedirs(self.tests_dir, exist_ok=True)
39+
self.test_recorder = TestRecorder(self.args.test_run_id, "smoke-test", self.tests_dir, args.base_path)
40+
self.save_log = self.test_recorder.test_results_logs
41+
self.version = ""
42+
43+
def start_test(self, work_dir: Path) -> Any:
44+
pass
45+
46+
def extract_paths_from_yaml(self, component: str, version: str) -> Any:
47+
base_path = os.path.dirname(os.path.abspath(__file__))
48+
paths = [
49+
os.path.join(base_path, "smoke_tests_spec", f"{version.split('.')[0]}.x", f"{component}.yml"),
50+
os.path.join(base_path, "smoke_tests_spec", "default", f"{component}.yml")
51+
]
52+
for file_path in paths:
53+
if os.path.exists(file_path):
54+
logging.info(f"Component spec for {component} with path {file_path} is found.")
55+
with open(file_path, 'r') as file:
56+
data = yaml.safe_load(file) # Load the YAML content
57+
# Extract paths
58+
paths = data.get('paths', {})
59+
return paths
60+
logging.error("No spec found.")
61+
sys.exit(1)
62+
63+
def convert_parameter_json(self, data: list) -> str:
64+
return "\n".join(json.dumps(item) for item in data) + "\n" if data else ""
65+
66+
# Essential of initiate the testing phase. This function is called by the run_smoke_test.py
67+
def run(self) -> Any:
68+
with TemporaryDirectory(keep=self.args.keep, chdir=True) as work_dir:
69+
70+
logging.info("Initiating smoke tests.")
71+
test_cluster = SmokeTestClusterOpenSearch(self.args, os.path.join(work_dir.path), self.test_recorder)
72+
self.version = test_cluster.cluster_version()
73+
test_cluster.__start_cluster__(os.path.join(work_dir.path))
74+
is_cluster_ready = False
75+
for i in range(10):
76+
logging.info(f"Attempt {i} of 10 to check cluster.")
77+
if test_cluster.__check_cluster_ready__():
78+
is_cluster_ready = True
79+
break
80+
time.sleep(10)
81+
try:
82+
if is_cluster_ready:
83+
results_data = self.start_test(work_dir.path)
84+
else:
85+
logging.info("Cluster is not ready after 10 attempts.")
86+
finally:
87+
logging.info("Terminating and uninstalling the cluster.")
88+
test_cluster.__uninstall__()
89+
return results_data
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright OpenSearch Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OpenSearch Contributors require contributions made to
5+
# this file be licensed under the Apache-2.0 license or a
6+
# compatible open source license.
7+
8+
import logging
9+
import os
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import requests
14+
from openapi_core import Spec, validate_request, validate_response
15+
from openapi_core.contrib.requests import RequestsOpenAPIRequest, RequestsOpenAPIResponse
16+
17+
from manifests.test_manifest import TestManifest
18+
from test_workflow.smoke_test.smoke_test_runner import SmokeTestRunner
19+
from test_workflow.test_args import TestArgs
20+
from test_workflow.test_result.test_component_results import TestComponentResults
21+
from test_workflow.test_result.test_result import TestResult
22+
from test_workflow.test_result.test_suite_results import TestSuiteResults
23+
24+
25+
class SmokeTestRunnerOpenSearch(SmokeTestRunner):
26+
27+
def __init__(self, args: TestArgs, test_manifest: TestManifest) -> None:
28+
super().__init__(args, test_manifest)
29+
logging.info("Entering Smoke test for OpenSearch Bundle.")
30+
31+
# TODO: Download the spec from https://github.com/opensearch-project/opensearch-api-specification/releases/download/main-latest/opensearch-openapi.yaml
32+
spec_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "smoke_tests_spec", "opensearch-openapi.yaml")
33+
self.spec_ = Spec.from_file_path(spec_file)
34+
self.mimetype = {
35+
"Content-Type": "application/json"
36+
}
37+
# self.openapi = openapi_core.OpenAPI.from_file_path(spec_file)
38+
39+
def validate_request_swagger(self, request: Any) -> None:
40+
request = RequestsOpenAPIRequest(request)
41+
validate_request(request=request, spec=self.spec_)
42+
logging.info("Request is validated.")
43+
44+
def validate_response_swagger(self, response: Any) -> None:
45+
request = RequestsOpenAPIRequest(response.request)
46+
response = RequestsOpenAPIResponse(response)
47+
validate_response(response=response, spec=self.spec_, request=request)
48+
logging.info("Response is validated.")
49+
50+
def start_test(self, work_dir: Path) -> TestSuiteResults:
51+
url = "https://localhost:9200"
52+
53+
all_results = TestSuiteResults()
54+
for component in self.test_manifest.components.select(self.args.components):
55+
if component.smoke_test:
56+
logging.info(f"Running smoke test on {component.name} component.")
57+
component_spec = self.extract_paths_from_yaml(component.name, self.version)
58+
logging.info(f"component spec is {component_spec}")
59+
test_results = TestComponentResults()
60+
for api_requests, api_details in component_spec.items():
61+
request_url = ''.join([url, api_requests])
62+
logging.info(f"Validating api request {api_requests}")
63+
logging.info(f"API request URL is {request_url}")
64+
for method in api_details.keys(): # Iterates over each method, e.g., "GET", "POST"
65+
requests_method = getattr(requests, method.lower())
66+
parameters_data = self.convert_parameter_json(api_details.get(method).get("parameters"))
67+
header = api_details.get(method).get("header", self.mimetype)
68+
logging.info(f"Parameter is {parameters_data} and type is {type(parameters_data)}")
69+
logging.info(f"header is {header}")
70+
status = 0
71+
try:
72+
response = requests_method(request_url, verify=False, auth=("admin", "myStrongPassword123!"), headers=header, data=parameters_data)
73+
logging.info(f"Response is {response.text}")
74+
self.validate_response_swagger(response)
75+
except:
76+
status = 1
77+
finally:
78+
test_result = TestResult(component.name, ' '.join([api_requests, method]), status) # type: ignore
79+
test_results.append(test_result)
80+
all_results.append(component.name, test_results)
81+
return all_results
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright OpenSearch Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The OpenSearch Contributors require contributions made to
5+
# this file be licensed under the Apache-2.0 license or a
6+
# compatible open source license.
7+
8+
9+
from manifests.test_manifest import TestManifest
10+
from test_workflow.smoke_test.smoke_test_runner import SmokeTestRunner
11+
from test_workflow.smoke_test.smoke_test_runner_opensearch import SmokeTestRunnerOpenSearch
12+
from test_workflow.test_args import TestArgs
13+
14+
15+
class SmokeTestRunners:
16+
RUNNERS = {
17+
"OpenSearch": SmokeTestRunnerOpenSearch
18+
}
19+
20+
@classmethod
21+
def from_test_manifest(cls, args: TestArgs, test_manifest: TestManifest) -> SmokeTestRunner:
22+
return cls.RUNNERS[test_manifest.name](args, test_manifest)

0 commit comments

Comments
 (0)