|
| 1 | +#!/bin/python3 |
| 2 | + |
| 3 | +# SPDX-FileCopyrightText: Copyright 2025 Siemens AG |
| 4 | +# |
| 5 | +# SPDX-License-Identifier: Apache-2.0 |
| 6 | + |
| 7 | +# pylint: disable=invalid-name |
| 8 | + |
| 9 | +"""Entry point script for running the CMP test suite in a Docker container. |
| 10 | +
|
| 11 | +It aims to simplify the user experience, by exposing a minimal command line interface |
| 12 | +for doing simple things (e.g., run smoke tests), while also allowing complex use cases, |
| 13 | +when necessary (e.g., run all tests with a custom configuration). |
| 14 | +""" |
| 15 | + |
| 16 | +import argparse |
| 17 | +import logging |
| 18 | +import os |
| 19 | +import subprocess |
| 20 | +import sys |
| 21 | +from pathlib import Path |
| 22 | +from urllib.parse import urlparse |
| 23 | + |
| 24 | +log = logging.getLogger("cmptest") |
| 25 | + |
| 26 | + |
| 27 | +def valid_url(arg): |
| 28 | + """Check whether the given URL is valid.""" |
| 29 | + url = urlparse(arg) |
| 30 | + if all((url.scheme, url.netloc)): |
| 31 | + return arg |
| 32 | + raise argparse.ArgumentTypeError("Invalid URL") |
| 33 | + |
| 34 | + |
| 35 | +def run_robot_command(command, verbose=False): |
| 36 | + """Run the robot command and return its exit code.""" |
| 37 | + if verbose: |
| 38 | + log.info("Run: %s", command) |
| 39 | + try: |
| 40 | + with subprocess.Popen( |
| 41 | + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True |
| 42 | + ) as process: |
| 43 | + # Stream output in real-time |
| 44 | + while True: |
| 45 | + stdout_line = process.stdout.readline() |
| 46 | + stderr_line = process.stderr.readline() |
| 47 | + |
| 48 | + if stdout_line: |
| 49 | + log.info(stdout_line.strip()) |
| 50 | + if stderr_line: |
| 51 | + log.error(stderr_line.strip()) |
| 52 | + |
| 53 | + if not stdout_line and not stderr_line and process.poll() is not None: |
| 54 | + break |
| 55 | + |
| 56 | + exit_code = process.poll() |
| 57 | + log.info("Command completed with exit code: %s", exit_code) |
| 58 | + return exit_code |
| 59 | + except OSError as e: |
| 60 | + log.error("Error executing command: %s", e) |
| 61 | + return 1 |
| 62 | + |
| 63 | + |
| 64 | +class CustomConfigAction(argparse.Action): |
| 65 | + """ |
| 66 | + Custom argparse action to handle the --customconfig argument. |
| 67 | +
|
| 68 | + Distinguishes between explicit usage (with or without a value) and complete omission of the argument. |
| 69 | + """ |
| 70 | + |
| 71 | + def __call__(self, parser, namespace, values, option_string=None): # noqa: D102 no docstring |
| 72 | + setattr(namespace, self.dest, values if values else Path("config/")) |
| 73 | + setattr(namespace, f"{self.dest}_explicit", True) |
| 74 | + |
| 75 | + |
| 76 | +def prepare_parser(): |
| 77 | + """Parse command line arguments.""" |
| 78 | + parser = argparse.ArgumentParser( |
| 79 | + description="CMP test suite tool", |
| 80 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 81 | + epilog="""Examples of usage, assume the docker image is called `image`: |
| 82 | +
|
| 83 | +1. Default behavior (no additional arguments): |
| 84 | + docker run --rm -it ghcr.io/siemens/cmp-test |
| 85 | + Executes: robot --pythonpath=./ --outputdir=/report --include smoke scripts/smoke.robot tests/ |
| 86 | + Does not require any configuration, runs the smoke test and any other test with the `smoke` tag. |
| 87 | +
|
| 88 | +2. Passing a minimal URL: |
| 89 | + docker run --rm -it ghcr.io/siemens/cmp-test --minimal http://example.com |
| 90 | + Executes: robot --pythonpath=./ --outputdir=/report --include minimal --variable SERVER_URL:http://example.com tests/ |
| 91 | + Runs only tests tagged `minimal`, which only require the server's address and nothing else. |
| 92 | +
|
| 93 | +3. Using a custom configuration: |
| 94 | + docker run --rm -it ghcr.io/siemens/cmp-test -v ./reports:/report -v ./config:/config image --customconfig |
| 95 | + Runs all tests with the custom configuration given in a directory mounted to config/, will save reports to /reports. |
| 96 | +
|
| 97 | +4. Passing arbitrary arguments to robot (note the `--`): |
| 98 | + docker run --rm -it ghcr.io/siemens/cmp-test --minimal http://example.com -- --dryrun |
| 99 | + Runs: robot --pythonpath=./ --outputdir=/report --include minimal --variable SERVER_URL:http://example.com tests/ \ |
| 100 | + --dryrun |
| 101 | +""", |
| 102 | + ) |
| 103 | + |
| 104 | + group = parser.add_mutually_exclusive_group() |
| 105 | + group.add_argument("--smoke", action="store_true", help="Run smoke tests that don't require any configuration") |
| 106 | + group.add_argument("--minimal", help="Run tests that only need a CMP URL endpoint", type=valid_url, metavar="URL") |
| 107 | + group.add_argument( |
| 108 | + "--customconfig", |
| 109 | + help="Use custom configuration directory and run all tests", |
| 110 | + type=Path, |
| 111 | + nargs="?", |
| 112 | + default=Path("config/"), |
| 113 | + metavar="DIR", |
| 114 | + action=CustomConfigAction, |
| 115 | + ) |
| 116 | + parser.add_argument("--tags", help="Run only tests with the given tags", type=str, nargs="+", default=[]) |
| 117 | + parser.add_argument( |
| 118 | + "--ephemeral", |
| 119 | + help="Discard the detailed report generated by the test suite, the results printed on the screen are enough", |
| 120 | + action="store_true", |
| 121 | + default=False, |
| 122 | + ) |
| 123 | + parser.add_argument( |
| 124 | + "--verbose", help="Display additional debugging information", action="store_true", default=False |
| 125 | + ) |
| 126 | + |
| 127 | + parser.add_argument( |
| 128 | + "robot_args", nargs=argparse.REMAINDER, help="Optional arguments to pass directlyto Robot Framework." |
| 129 | + ) |
| 130 | + |
| 131 | + # Add a flag to track explicit usage of --customconfig, to differentiate between |
| 132 | + # when it was provided with a value, without a value, or not at all |
| 133 | + parser.set_defaults(customconfig_explicit=False) |
| 134 | + |
| 135 | + return parser |
| 136 | + |
| 137 | + |
| 138 | +def verify_report_directory(ephemeral, smoke, verbose): |
| 139 | + """Check if the report directory exists and enforce some checks based on settings. |
| 140 | +
|
| 141 | + Args: |
| 142 | + ephemeral (bool): If True, we don't care about potentially losing the detailed report. |
| 143 | + smoke (bool): If True, we only run smoke tests. |
| 144 | + verbose (bool): If True, additional information will be logged. |
| 145 | +
|
| 146 | + """ |
| 147 | + if os.path.exists("/report"): |
| 148 | + if verbose: |
| 149 | + log.info("Report will be saved to /report (inside your container)") |
| 150 | + else: |
| 151 | + if ephemeral or smoke: |
| 152 | + log.info( |
| 153 | + "Running in ephemeral mode, only brief results on screen, no file report. Consider running the " |
| 154 | + "container with `-v /path/on/host:/report` to ensure the report is saved." |
| 155 | + ) |
| 156 | + else: |
| 157 | + log.warning( |
| 158 | + "The /report directory does not exist, the detailed test report will be lost after " |
| 159 | + "the container is removed. Run with --ephemeral to ignore this and only consider the " |
| 160 | + "results shown on stdout, or run the container with `-v /path/on/host:/report` to " |
| 161 | + "ensure the report is saved." |
| 162 | + ) |
| 163 | + sys.exit(1) |
| 164 | + |
| 165 | + |
| 166 | +def main(): |
| 167 | + """Run the CMP test suite.""" |
| 168 | + parser = prepare_parser() |
| 169 | + args = parser.parse_args() |
| 170 | + |
| 171 | + if args.verbose: |
| 172 | + log.debug(args) |
| 173 | + |
| 174 | + verify_report_directory(args.ephemeral, args.smoke, args.verbose) |
| 175 | + |
| 176 | + if args.robot_args: |
| 177 | + # One can pass additional arguments to RobotFramework by adding -- <robot args> at the end |
| 178 | + # of the command line, e.g. `docker run image --minimal http://example.com -- --dryrun`. |
| 179 | + # We prepare this piece here because we might need to add it later. |
| 180 | + additional_args = " " + " ".join(args.robot_args[1:]) # omit the "--" itself |
| 181 | + else: |
| 182 | + additional_args = "" |
| 183 | + |
| 184 | + if args.smoke: |
| 185 | + # Run the smoke test in scripts/smoke.robot, as well as any other test with the `smoke` tag |
| 186 | + command = "robot --pythonpath=./ --outputdir=/report --include smoke scripts/smoke.robot" |
| 187 | + elif args.minimal: |
| 188 | + # A minimal batch of tests that only need to know the server's address and nothing else, it is the easiest |
| 189 | + # way to get a taste of what the test suite can do while still doing some actual work with a real server. |
| 190 | + command = f"robot --pythonpath=./ --outputdir=/report --include minimal --variable CA_CMP_URL:{args.minimal}" |
| 191 | + |
| 192 | + elif args.customconfig_explicit: |
| 193 | + # The script was started with --customconfig, there are several possibilities: |
| 194 | + # [A] --customconfig without a specific path, so the default one is implied |
| 195 | + # [B] --customconfig with a specific path to a custom directory |
| 196 | + if args.customconfig == parser.get_default("customconfig"): |
| 197 | + # case [A], load the config from /config, where we expect it to be mounted when running within docker. |
| 198 | + config_path = Path("/config") |
| 199 | + else: |
| 200 | + # case [B], load the config from the path given by the user |
| 201 | + config_path = args.customconfig |
| 202 | + |
| 203 | + if not os.path.exists(config_path / "custom.robot"): |
| 204 | + config_guide = """It must be mounted to /config in the docker container (override with --customconfig) |
| 205 | +and follow this structure: |
| 206 | +/config |
| 207 | +├── custom.robot - set the relevant variables, follow the examples given in config/ in the repo |
| 208 | +├── data/ - a directory with data files (keys, certificates, etc.) needed by your custom.robot""" |
| 209 | + log.warning("The configuration directory does not exist or is not structured correctly. %s", config_guide) |
| 210 | + sys.exit(1) |
| 211 | + |
| 212 | + # If we end up here, it means that the user took the time to set up a configuration directory and adjust |
| 213 | + # it to the specifics of their CMP server. We'll run all tests and rely on the given configuration and |
| 214 | + # data (e.g., certificates, keypairs, preshared passwords, etc.) |
| 215 | + if args.tags: |
| 216 | + included_tags = "--include " + " --include ".join(args.tags) |
| 217 | + else: |
| 218 | + included_tags = "" |
| 219 | + |
| 220 | + command = f"robot --pythonpath=./ --outputdir=/report --variable environment:custom {included_tags}" |
| 221 | + else: |
| 222 | + log.debug("No actionable command line arguments given, nothing to do") |
| 223 | + sys.exit(0) |
| 224 | + |
| 225 | + # Prepare the final command, appending additional arguments, if any, and ensuring that test/ is in |
| 226 | + # the end. |
| 227 | + command = f"{command} {additional_args} tests/" |
| 228 | + |
| 229 | + run_robot_command(command) |
| 230 | + |
| 231 | + |
| 232 | +if __name__ == "__main__": |
| 233 | + logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)5s - %(message)s") |
| 234 | + |
| 235 | + log.info("Starting CMP test suite") |
| 236 | + main() |
0 commit comments