Skip to content

Commit 95cb624

Browse files
authored
[ISV-5274] Prepare the integration test framework scaffolding (1/2) (#749)
1 parent 465d83a commit 95cb624

File tree

17 files changed

+1389
-90
lines changed

17 files changed

+1389
-90
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,4 @@ ansible/vault-password*
149149

150150
.pdm-python
151151

152+
integration-tests-config.yaml

ansible/roles/config_ocp_cluster/tasks/main.yml

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- chat
2121
ansible.builtin.include_vars:
2222
file: ../../vaults/pipelinerun-listener/secret-vars.yml
23+
no_log: true
2324

2425
- name: Include Chat trigger
2526
tags:

integration-tests-config-sample.yaml

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
3+
operator_repository:
4+
# The GitHub repository hosting the operators for integration tests
5+
url: https://github.com/foo/operators-integration-tests
6+
token: secret123
7+
contributor_repository:
8+
# The GitHub repository hosting where to fork the integration tests repo and submit the PR from
9+
url: https://github.com/bar/operators-integration-tests-fork
10+
token: secret456
11+
ssh_key: ~/.ssh/id_rsa_alt
12+
bundle_registry: &quay
13+
# The container registry where the bundle and index images will be pushed to
14+
base_ref: quay.io/foo
15+
username: foo
16+
password: secret789
17+
test_registry: *quay
18+
# The container registry where to push the operator-pipeline image
19+
iib:
20+
# The iib instance to use to manipulate indices
21+
url: https://iib.stage.engineering.redhat.com
22+
keytab: /tmp/keytab
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Integration tests for the operator-pipelines project
3+
"""
4+
5+
import argparse
6+
import logging
7+
import sys
8+
from pathlib import Path
9+
10+
from operatorcert.integration.runner import run_integration_tests
11+
12+
LOGGER = logging.getLogger("operator-cert")
13+
14+
15+
def parse_args() -> argparse.Namespace:
16+
"""
17+
Parse command line arguments
18+
19+
Returns:
20+
Parsed arguments
21+
"""
22+
parser = argparse.ArgumentParser(description="Run integration tests")
23+
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
24+
parser.add_argument(
25+
"--image", "-i", help="Skip image build and use alternate container image"
26+
)
27+
parser.add_argument(
28+
"directory", type=Path, help="operator-pipelines project directory"
29+
)
30+
parser.add_argument("config_file", type=Path, help="Path to the yaml config file")
31+
32+
return parser.parse_args()
33+
34+
35+
def setup_logging(verbose: bool) -> None:
36+
"""
37+
Set up the logging configuration for the application.
38+
39+
Args:
40+
verbose (bool): If True, set the logging level to DEBUG; otherwise, set it to INFO.
41+
42+
This function configures the logging format and level for the application, allowing for
43+
detailed debug messages when verbose mode is enabled.
44+
"""
45+
46+
logging.basicConfig(
47+
format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
48+
level=logging.DEBUG if verbose else logging.INFO,
49+
)
50+
51+
52+
def main() -> int:
53+
"""
54+
Main function for integration tests runner
55+
"""
56+
args = parse_args()
57+
setup_logging(args.verbose)
58+
59+
# Logic
60+
return run_integration_tests(args.directory, args.config_file, args.image)
61+
62+
63+
if __name__ == "__main__": # pragma: no cover
64+
sys.exit(main())

operator-pipeline-images/operatorcert/integration/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Schema of the integration tests configuration file
3+
"""
4+
5+
from pathlib import Path
6+
from typing import Optional, Type, TypeVar
7+
8+
from pydantic import BaseModel
9+
from yaml import safe_load
10+
11+
12+
class GitHubRepoConfig(BaseModel):
13+
"""
14+
A GitHub repository
15+
"""
16+
17+
url: str
18+
token: Optional[str] = None
19+
ssh_key: Optional[Path] = None
20+
21+
22+
class ContainerRegistryConfig(BaseModel):
23+
"""
24+
A container registry
25+
"""
26+
27+
base_ref: str
28+
username: Optional[str] = None
29+
password: Optional[str] = None
30+
31+
32+
class IIBConfig(BaseModel):
33+
"""
34+
An IIB API endpoint
35+
"""
36+
37+
url: str
38+
keytab: Path
39+
40+
41+
C = TypeVar("C", bound="Config")
42+
43+
44+
class Config(BaseModel):
45+
"""
46+
Root configuration object
47+
"""
48+
49+
operator_repository: GitHubRepoConfig
50+
contributor_repository: GitHubRepoConfig
51+
bundle_registry: ContainerRegistryConfig
52+
test_registry: ContainerRegistryConfig
53+
iib: IIBConfig
54+
55+
@classmethod
56+
def from_yaml(cls: Type[C], path: Path) -> C:
57+
"""
58+
Parse a yaml configuration file
59+
60+
Args:
61+
path: path to the configuration file
62+
63+
Returns:
64+
the parsed configuration object
65+
"""
66+
return cls(**safe_load(path.read_text()))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Utility classes to run external tools
3+
"""
4+
5+
import base64
6+
import json
7+
import logging
8+
import subprocess
9+
import tempfile
10+
from os import PathLike
11+
from pathlib import Path
12+
from typing import Mapping, Optional, Sequence, TypeAlias
13+
14+
LOGGER = logging.getLogger("operator-cert")
15+
16+
17+
CommandArg: TypeAlias = str | PathLike[str]
18+
19+
20+
class Secret(str):
21+
"""
22+
A string with sensitive content that should not be logged
23+
"""
24+
25+
26+
def run(
27+
*cmd: CommandArg,
28+
cwd: Optional[CommandArg] = None,
29+
env: Optional[Mapping[str, str]] = None,
30+
) -> None:
31+
"""
32+
Execute an external command
33+
34+
Args:
35+
*cmd: The command and its arguments
36+
cwd: Directory to run the command in, by default current working directory
37+
env: Environment variables, if None the current environment is used
38+
39+
Raises:
40+
subprocess.CalledProcessError when the called process exits with a
41+
non-zero status; the process' stdout and stderr can be obtained
42+
from the exception object
43+
"""
44+
LOGGER.debug("Running %s from %s", cmd, cwd or Path.cwd())
45+
subprocess.run(
46+
cmd,
47+
stdout=subprocess.PIPE,
48+
stderr=subprocess.STDOUT,
49+
check=True,
50+
cwd=cwd,
51+
env=env,
52+
)
53+
54+
55+
class Ansible:
56+
"""
57+
Utility class to interact with Ansible
58+
"""
59+
60+
def __init__(self, path: Optional[Path]) -> None:
61+
"""
62+
Initialize the Ansible instance
63+
64+
Args:
65+
path: The working directory for the ansible commands;
66+
It must contain an ansible.cfg file
67+
"""
68+
self.path = (path or Path.cwd()).absolute()
69+
# Simple sanity check to ensure the directory actually contains
70+
# a copy of the operator-pipelines project
71+
ansible_cfg_file = self.path / "ansible.cfg"
72+
if not ansible_cfg_file.exists():
73+
raise FileNotFoundError(f"ansible.cfg not found in {self.path}")
74+
75+
def playbook_path(self, playbook_name: str) -> Path:
76+
"""
77+
Return the path to the playbook file with the given name; this is specific
78+
to the operator-pipelines project
79+
"""
80+
playbook_dir = self.path / "ansible" / "playbooks"
81+
for ext in ("yml", "yaml"):
82+
playbook_path = playbook_dir / f"{playbook_name}.{ext}"
83+
if playbook_path.exists():
84+
return playbook_path
85+
raise FileNotFoundError(f"Playbook {playbook_name} not found in {playbook_dir}")
86+
87+
def run_playbook(
88+
self, playbook: str, *extra_args: CommandArg, **extra_vars: str | Secret
89+
) -> None:
90+
"""
91+
Run an ansible playbook
92+
93+
Args:
94+
playbook: The name of the playbook to execute
95+
*extra_args: Additional arguments for the ansible playbook
96+
**extra_vars: Extra variables to pass to the playbook
97+
"""
98+
command: list[CommandArg] = ["ansible-playbook", self.playbook_path(playbook)]
99+
command.extend(extra_args)
100+
secrets = {}
101+
for k, v in extra_vars.items():
102+
if isinstance(v, Secret):
103+
# Avoid adding secrets to the command line
104+
secrets[k] = str(v)
105+
else:
106+
command.extend(["-e", f"{k}={v}"])
107+
with tempfile.NamedTemporaryFile(
108+
mode="w",
109+
encoding="utf-8",
110+
suffix=".json",
111+
delete=True,
112+
delete_on_close=False,
113+
) as tmp:
114+
if secrets:
115+
json.dump(secrets, tmp)
116+
command.extend(["-e", f"@{tmp.name}"])
117+
tmp.close()
118+
run(*command, cwd=self.path)
119+
120+
121+
class Podman:
122+
"""
123+
Utility class to interact with Podman.
124+
"""
125+
126+
def __init__(self, auth: Optional[Mapping[str, tuple[str, str]]] = None):
127+
"""
128+
Initialize the Podman instance
129+
130+
Args:
131+
auth: The authentication credentials for registries
132+
"""
133+
self._auth = {
134+
"auths": {
135+
registry: {
136+
"auth": base64.b64encode(
137+
f"{username}:{password}".encode("utf-8")
138+
).decode("ascii")
139+
}
140+
for registry, (username, password) in (auth or {}).items()
141+
if username and password
142+
}
143+
}
144+
145+
def _run(self, *args: CommandArg) -> None:
146+
"""
147+
Run a podman subcommand
148+
149+
Args:
150+
*args: The podman subcommand and its arguments
151+
"""
152+
command: list[CommandArg] = ["podman"]
153+
command.extend(args)
154+
with tempfile.NamedTemporaryFile(
155+
mode="w",
156+
encoding="utf-8",
157+
suffix=".json",
158+
delete=True,
159+
delete_on_close=False,
160+
) as tmp:
161+
json.dump(self._auth, tmp)
162+
tmp.close()
163+
LOGGER.debug("Using podman auth file: %s", tmp.name)
164+
run(*command, env={"REGISTRY_AUTH_FILE": tmp.name})
165+
166+
def build(
167+
self,
168+
context: CommandArg,
169+
image: str,
170+
containerfile: Optional[CommandArg] = None,
171+
extra_args: Optional[Sequence[CommandArg]] = None,
172+
) -> None:
173+
"""
174+
Build an image
175+
176+
Args:
177+
context: Directory to build the image from
178+
image: The name of the image to build
179+
containerfile: The path to the container configuration file,
180+
if not specified it will be inferred by podman
181+
extra_args: Additional arguments for the podman build command
182+
"""
183+
command: list[CommandArg] = ["build", "-t", image, context]
184+
if containerfile:
185+
command.extend(["-f", containerfile])
186+
if extra_args:
187+
command.extend(extra_args)
188+
self._run(*command)
189+
190+
def push(self, image: str) -> None:
191+
"""
192+
Push an image to a registry.
193+
194+
Args:
195+
image: The name of the image to push.
196+
"""
197+
self._run("push", image)

0 commit comments

Comments
 (0)