Skip to content

Commit

Permalink
[ISV-5274] Prepare the integration test framework scaffolding (1/2) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mporrato authored Dec 2, 2024
1 parent 465d83a commit 95cb624
Show file tree
Hide file tree
Showing 17 changed files with 1,389 additions and 90 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,4 @@ ansible/vault-password*

.pdm-python

integration-tests-config.yaml
1 change: 1 addition & 0 deletions ansible/roles/config_ocp_cluster/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- chat
ansible.builtin.include_vars:
file: ../../vaults/pipelinerun-listener/secret-vars.yml
no_log: true

- name: Include Chat trigger
tags:
Expand Down
22 changes: 22 additions & 0 deletions integration-tests-config-sample.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---

operator_repository:
# The GitHub repository hosting the operators for integration tests
url: https://github.com/foo/operators-integration-tests
token: secret123
contributor_repository:
# The GitHub repository hosting where to fork the integration tests repo and submit the PR from
url: https://github.com/bar/operators-integration-tests-fork
token: secret456
ssh_key: ~/.ssh/id_rsa_alt
bundle_registry: &quay
# The container registry where the bundle and index images will be pushed to
base_ref: quay.io/foo
username: foo
password: secret789
test_registry: *quay
# The container registry where to push the operator-pipeline image
iib:
# The iib instance to use to manipulate indices
url: https://iib.stage.engineering.redhat.com
keytab: /tmp/keytab
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Integration tests for the operator-pipelines project
"""

import argparse
import logging
import sys
from pathlib import Path

from operatorcert.integration.runner import run_integration_tests

LOGGER = logging.getLogger("operator-cert")


def parse_args() -> argparse.Namespace:
"""
Parse command line arguments
Returns:
Parsed arguments
"""
parser = argparse.ArgumentParser(description="Run integration tests")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument(
"--image", "-i", help="Skip image build and use alternate container image"
)
parser.add_argument(
"directory", type=Path, help="operator-pipelines project directory"
)
parser.add_argument("config_file", type=Path, help="Path to the yaml config file")

return parser.parse_args()


def setup_logging(verbose: bool) -> None:
"""
Set up the logging configuration for the application.
Args:
verbose (bool): If True, set the logging level to DEBUG; otherwise, set it to INFO.
This function configures the logging format and level for the application, allowing for
detailed debug messages when verbose mode is enabled.
"""

logging.basicConfig(
format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
level=logging.DEBUG if verbose else logging.INFO,
)


def main() -> int:
"""
Main function for integration tests runner
"""
args = parse_args()
setup_logging(args.verbose)

# Logic
return run_integration_tests(args.directory, args.config_file, args.image)


if __name__ == "__main__": # pragma: no cover
sys.exit(main())
Empty file.
66 changes: 66 additions & 0 deletions operator-pipeline-images/operatorcert/integration/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Schema of the integration tests configuration file
"""

from pathlib import Path
from typing import Optional, Type, TypeVar

from pydantic import BaseModel
from yaml import safe_load


class GitHubRepoConfig(BaseModel):
"""
A GitHub repository
"""

url: str
token: Optional[str] = None
ssh_key: Optional[Path] = None


class ContainerRegistryConfig(BaseModel):
"""
A container registry
"""

base_ref: str
username: Optional[str] = None
password: Optional[str] = None


class IIBConfig(BaseModel):
"""
An IIB API endpoint
"""

url: str
keytab: Path


C = TypeVar("C", bound="Config")


class Config(BaseModel):
"""
Root configuration object
"""

operator_repository: GitHubRepoConfig
contributor_repository: GitHubRepoConfig
bundle_registry: ContainerRegistryConfig
test_registry: ContainerRegistryConfig
iib: IIBConfig

@classmethod
def from_yaml(cls: Type[C], path: Path) -> C:
"""
Parse a yaml configuration file
Args:
path: path to the configuration file
Returns:
the parsed configuration object
"""
return cls(**safe_load(path.read_text()))
197 changes: 197 additions & 0 deletions operator-pipeline-images/operatorcert/integration/external_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""
Utility classes to run external tools
"""

import base64
import json
import logging
import subprocess
import tempfile
from os import PathLike
from pathlib import Path
from typing import Mapping, Optional, Sequence, TypeAlias

LOGGER = logging.getLogger("operator-cert")


CommandArg: TypeAlias = str | PathLike[str]


class Secret(str):
"""
A string with sensitive content that should not be logged
"""


def run(
*cmd: CommandArg,
cwd: Optional[CommandArg] = None,
env: Optional[Mapping[str, str]] = None,
) -> None:
"""
Execute an external command
Args:
*cmd: The command and its arguments
cwd: Directory to run the command in, by default current working directory
env: Environment variables, if None the current environment is used
Raises:
subprocess.CalledProcessError when the called process exits with a
non-zero status; the process' stdout and stderr can be obtained
from the exception object
"""
LOGGER.debug("Running %s from %s", cmd, cwd or Path.cwd())
subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=True,
cwd=cwd,
env=env,
)


class Ansible:
"""
Utility class to interact with Ansible
"""

def __init__(self, path: Optional[Path]) -> None:
"""
Initialize the Ansible instance
Args:
path: The working directory for the ansible commands;
It must contain an ansible.cfg file
"""
self.path = (path or Path.cwd()).absolute()
# Simple sanity check to ensure the directory actually contains
# a copy of the operator-pipelines project
ansible_cfg_file = self.path / "ansible.cfg"
if not ansible_cfg_file.exists():
raise FileNotFoundError(f"ansible.cfg not found in {self.path}")

def playbook_path(self, playbook_name: str) -> Path:
"""
Return the path to the playbook file with the given name; this is specific
to the operator-pipelines project
"""
playbook_dir = self.path / "ansible" / "playbooks"
for ext in ("yml", "yaml"):
playbook_path = playbook_dir / f"{playbook_name}.{ext}"
if playbook_path.exists():
return playbook_path
raise FileNotFoundError(f"Playbook {playbook_name} not found in {playbook_dir}")

def run_playbook(
self, playbook: str, *extra_args: CommandArg, **extra_vars: str | Secret
) -> None:
"""
Run an ansible playbook
Args:
playbook: The name of the playbook to execute
*extra_args: Additional arguments for the ansible playbook
**extra_vars: Extra variables to pass to the playbook
"""
command: list[CommandArg] = ["ansible-playbook", self.playbook_path(playbook)]
command.extend(extra_args)
secrets = {}
for k, v in extra_vars.items():
if isinstance(v, Secret):
# Avoid adding secrets to the command line
secrets[k] = str(v)
else:
command.extend(["-e", f"{k}={v}"])
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".json",
delete=True,
delete_on_close=False,
) as tmp:
if secrets:
json.dump(secrets, tmp)
command.extend(["-e", f"@{tmp.name}"])
tmp.close()
run(*command, cwd=self.path)


class Podman:
"""
Utility class to interact with Podman.
"""

def __init__(self, auth: Optional[Mapping[str, tuple[str, str]]] = None):
"""
Initialize the Podman instance
Args:
auth: The authentication credentials for registries
"""
self._auth = {
"auths": {
registry: {
"auth": base64.b64encode(
f"{username}:{password}".encode("utf-8")
).decode("ascii")
}
for registry, (username, password) in (auth or {}).items()
if username and password
}
}

def _run(self, *args: CommandArg) -> None:
"""
Run a podman subcommand
Args:
*args: The podman subcommand and its arguments
"""
command: list[CommandArg] = ["podman"]
command.extend(args)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".json",
delete=True,
delete_on_close=False,
) as tmp:
json.dump(self._auth, tmp)
tmp.close()
LOGGER.debug("Using podman auth file: %s", tmp.name)
run(*command, env={"REGISTRY_AUTH_FILE": tmp.name})

def build(
self,
context: CommandArg,
image: str,
containerfile: Optional[CommandArg] = None,
extra_args: Optional[Sequence[CommandArg]] = None,
) -> None:
"""
Build an image
Args:
context: Directory to build the image from
image: The name of the image to build
containerfile: The path to the container configuration file,
if not specified it will be inferred by podman
extra_args: Additional arguments for the podman build command
"""
command: list[CommandArg] = ["build", "-t", image, context]
if containerfile:
command.extend(["-f", containerfile])
if extra_args:
command.extend(extra_args)
self._run(*command)

def push(self, image: str) -> None:
"""
Push an image to a registry.
Args:
image: The name of the image to push.
"""
self._run("push", image)
Loading

0 comments on commit 95cb624

Please sign in to comment.