From ba8a76aaeba74fdc5dfbb987d6693631d97a3a9d Mon Sep 17 00:00:00 2001 From: Denys Fedoryshchenko Date: Tue, 10 Mar 2026 13:48:04 +0200 Subject: [PATCH] feat(submit): add KCIDB build submission subcommand and payload helpers Added `kci-dev submit build` command with: - KCIDB payload ID generation and construction helpers - JSON dry-run support and full payload submission to KCIDB REST - credential resolution precedence: CLI > KCIDB_REST env var > config file (to make it partially compatible with existing KCIDB CLI usage patterns) - submit option grouping + CLI registration - tests for deterministic IDs, payload builders, config resolution, and help output Refs: #263 Signed-off-by: Denys Fedoryshchenko --- docs/config_file.md | 8 +- kcidev/_data/kci-dev.toml.example | 6 +- kcidev/libs/common.py | 2 +- kcidev/libs/kcidb.py | 210 ++++++++++++++++++++++ kcidev/main.py | 21 +-- kcidev/subcommands/submit/__init__.py | 201 +++++++++++++++++++++ kcidev/subcommands/submit/options.py | 117 +++++++++++++ tests/test_kcidb.py | 243 ++++++++++++++++++++++++++ tests/test_kcidev.py | 22 +++ 9 files changed, 816 insertions(+), 14 deletions(-) create mode 100644 kcidev/libs/kcidb.py create mode 100644 kcidev/subcommands/submit/__init__.py create mode 100644 kcidev/subcommands/submit/options.py create mode 100644 tests/test_kcidb.py diff --git a/docs/config_file.md b/docs/config_file.md index b176c51..586b2c2 100644 --- a/docs/config_file.md +++ b/docs/config_file.md @@ -29,16 +29,20 @@ token="example" pipeline="https://staging.kernelci.org:9100/" api="https://staging.kernelci.org:9000/" token="example" +kcidb_rest_url="https://db.kernelci.org/submit" +kcidb_token="your-kcidb-token-here" [production] pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/" api="https://kernelci-api.westus3.cloudapp.azure.com/" token="example" +kcidb_rest_url="https://staging.kcidb.kernelci.org/submit" +kcidb_token="your-kcidb-token-here" ``` Where `default_instance` is the default instance to use, if not provided in the command line. In section `local`, `staging`, `production` you can provide the host for the pipeline, api and also a token for the available instances. -pipeline is the URL of the KernelCI Pipeline API endpoint, api is the URL of the new KernelCI API endpoint, and token is the API token to use for authentication. -If you are using KernelCI Pipeline instance, you can get the token from the project maintainers. +`pipeline` is the URL of the KernelCI Pipeline API endpoint, `api` is the URL of the new KernelCI API endpoint, and `token` is the API token to use for authentication. `kcidb_rest_uri` is KCIDB submission endpoint, and `kcidb_token` is the API token to use for authentication with KCIDB. +If you are using KernelCI instances of pipeline or/and KCIDB, you can get the token from the KernelCI project maintainers. If it is a local instance, you can generate your token using [kernelci-pipeline/tools/jwt_generator.py](https://github.com/kernelci/kernelci-pipeline/blob/main/tools/jwt_generator.py) script. diff --git a/kcidev/_data/kci-dev.toml.example b/kcidev/_data/kci-dev.toml.example index 0e2c5fa..aaeb0a7 100644 --- a/kcidev/_data/kci-dev.toml.example +++ b/kcidev/_data/kci-dev.toml.example @@ -9,8 +9,12 @@ token="example" pipeline="https://staging.kernelci.org:9100/" api="https://staging.kernelci.org:9000/" token="example" +kcidb_rest_url="https://staging.kcidb.kernelci.org/submit" +kcidb_token="your-kcidb-token-here" [production] pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/" api="https://kernelci-api.westus3.cloudapp.azure.com/" -token="example" \ No newline at end of file +token="example" +kcidb_rest_url="https://db.kernelci.org/submit" +kcidb_token="your-kcidb-token-here" \ No newline at end of file diff --git a/kcidev/libs/common.py b/kcidev/libs/common.py index 48059c4..d5a4f9d 100644 --- a/kcidev/libs/common.py +++ b/kcidev/libs/common.py @@ -78,7 +78,7 @@ def load_toml(settings, subcommand): return config # config and results subcommand work without a config file - if subcommand != "config" and subcommand != "results": + if subcommand not in ("config", "results", "submit"): if not config: logging.warning(f"No config file found for subcommand {subcommand}") kci_err( diff --git a/kcidev/libs/kcidb.py b/kcidev/libs/kcidb.py new file mode 100644 index 0000000..edf53c6 --- /dev/null +++ b/kcidev/libs/kcidb.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import hashlib +import json +import os +import urllib.parse + +import click + +from kcidev.libs.common import kci_err, kci_msg, kci_warning, kcidev_session + +KCIDB_SCHEMA_MAJOR = 5 +KCIDB_SCHEMA_MINOR = 3 + + +# --------------------- +# ID generation +# --------------------- + + +def _sha256_hex(data): + return hashlib.sha256(data.encode("utf-8")).hexdigest() + + +def generate_checkout_id(origin, repo_url, branch, commit, patchset_hash=""): + """ + Deterministic checkout ID from source identity. + Format: origin:ck- + """ + seed = f"{repo_url}|{branch}|{commit}|{patchset_hash}" + return f"{origin}:ck-{_sha256_hex(seed)}" + + +def generate_build_id(origin, checkout_id, arch, config_name, compiler, start_time): + """ + Deterministic build ID from build parameters. + Format: origin:b- + """ + seed = f"{checkout_id}|{arch}|{config_name}|{compiler}|{start_time}" + return f"{origin}:b-{_sha256_hex(seed)}" + + +# --------------------- +# Payload building +# --------------------- + + +def build_checkout_payload(origin, checkout_id, **kwargs): + """Build a checkout object for the KCIDB JSON payload.""" + checkout = { + "id": checkout_id, + "origin": origin, + } + for key in [ + "tree_name", + "git_repository_url", + "git_repository_branch", + "git_commit_hash", + "patchset_hash", + "start_time", + "valid", + ]: + if kwargs.get(key) is not None: + checkout[key] = kwargs[key] + return checkout + + +def build_build_payload(origin, build_id, checkout_id, **kwargs): + """Build a build object for the KCIDB JSON payload.""" + build = { + "id": build_id, + "origin": origin, + "checkout_id": checkout_id, + } + for key in [ + "start_time", + "duration", + "architecture", + "compiler", + "config_name", + "config_url", + "log_url", + "comment", + "command", + "status", + ]: + if kwargs.get(key) is not None: + build[key] = kwargs[key] + return build + + +def build_submission_payload(checkouts, builds): + """Build the complete KCIDB v5.3 submission JSON.""" + payload = { + "version": {"major": KCIDB_SCHEMA_MAJOR, "minor": KCIDB_SCHEMA_MINOR}, + } + if checkouts: + payload["checkouts"] = checkouts + if builds: + payload["builds"] = builds + return payload + + +# --------------------- +# Config resolution +# --------------------- + + +def _parse_kcidb_rest_env(env_value): + """ + Parse KCIDB_REST env var. + Format: https://token@host[:port][/path] + Returns (url, token) or (None, None) on failure. + """ + parsed = urllib.parse.urlparse(env_value) + token = parsed.username + if not token: + return None, None + + # Rebuild URL without credentials + host = parsed.hostname + if parsed.port: + host = f"{host}:{parsed.port}" + path = parsed.path if parsed.path else "/" + if not path.endswith("/submit"): + path = path.rstrip("/") + "/submit" + + clean_url = urllib.parse.urlunparse( + (parsed.scheme, host, path, parsed.params, parsed.query, parsed.fragment) + ) + return clean_url, token + + +def resolve_kcidb_config(cfg, instance, cli_rest_url, cli_token): + """ + Resolve KCIDB REST URL and token. Priority: + 1. CLI flags (--kcidb-rest-url, --kcidb-token) + 2. KCIDB_REST environment variable + 3. Instance config (kcidb_rest_url, kcidb_token in TOML) + + Returns (rest_url, token) tuple. + Raises click.Abort if no valid credentials found. + """ + # 1. CLI flags + if cli_rest_url or cli_token: + if not cli_rest_url or not cli_token: + kci_err("Both --kcidb-rest-url and --kcidb-token must be provided together") + raise click.Abort() + return cli_rest_url, cli_token + + # 2. Environment variable + kcidb_rest_env = os.environ.get("KCIDB_REST") + if kcidb_rest_env: + url, token = _parse_kcidb_rest_env(kcidb_rest_env) + if url and token: + return url, token + kci_err("KCIDB_REST env var set but could not parse token from it") + + # 3. Instance config + if cfg and instance and instance in cfg: + inst_cfg = cfg[instance] + rest_url = inst_cfg.get("kcidb_rest_url") + token = inst_cfg.get("kcidb_token") + if rest_url and token: + return rest_url, token + + kci_err( + "No KCIDB credentials found. Provide --kcidb-rest-url and --kcidb-token, " + "set KCIDB_REST env var, or configure kcidb_rest_url/kcidb_token in config file" + ) + raise click.Abort() + + +# --------------------- +# REST submission +# --------------------- + + +def submit_to_kcidb(rest_url, token, payload, timeout=60): + """ + POST the JSON payload to the KCIDB REST API. + + Returns the response text on success. + Raises click.Abort on failure. + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + try: + response = kcidev_session.post( + rest_url, json=payload, headers=headers, timeout=timeout + ) + except Exception as e: + kci_err(f"KCIDB API connection error: {e}") + raise click.Abort() + + if response.status_code < 300: + try: + return response.json() + except Exception: + return response.text + else: + kci_err(f"KCIDB submission failed: HTTP {response.status_code}") + try: + kci_err(response.json()) + except Exception: + kci_err(response.text) + raise click.Abort() diff --git a/kcidev/main.py b/kcidev/main.py index 5db1c9b..305b19a 100755 --- a/kcidev/main.py +++ b/kcidev/main.py @@ -13,6 +13,7 @@ config, maestro, results, + submit, testretry, watch, ) @@ -40,19 +41,18 @@ def cli(ctx, settings, instance, debug): subcommand = ctx.invoked_subcommand ctx.obj = {"CFG": load_toml(settings, subcommand)} ctx.obj["SETTINGS"] = settings - if subcommand != "results" and subcommand != "config": + if subcommand not in ("results", "config"): if instance: ctx.obj["INSTANCE"] = instance - else: + elif subcommand != "submit": ctx.obj["INSTANCE"] = ctx.obj["CFG"].get("default_instance") - fconfig = config_path(settings) - if not ctx.obj["INSTANCE"]: - kci_err(f"No instance defined in settings or as argument in {fconfig}") - raise click.Abort() - if ctx.obj["INSTANCE"] not in ctx.obj["CFG"]: - kci_err(f"Instance {ctx.obj['INSTANCE']} not found in {fconfig}") - raise click.Abort() - pass + fconfig = config_path(settings) + if not ctx.obj["INSTANCE"]: + kci_err(f"No instance defined in settings or as argument in {fconfig}") + raise click.Abort() + if ctx.obj["INSTANCE"] not in ctx.obj["CFG"]: + kci_err(f"Instance {ctx.obj['INSTANCE']} not found in {fconfig}") + raise click.Abort() def run(): @@ -63,6 +63,7 @@ def run(): cli.add_command(maestro.maestro) cli.add_command(testretry.testretry) cli.add_command(results.results) + cli.add_command(submit.submit) cli.add_command(watch.watch) cli() diff --git a/kcidev/subcommands/submit/__init__.py b/kcidev/subcommands/submit/__init__.py new file mode 100644 index 0000000..ab462e4 --- /dev/null +++ b/kcidev/subcommands/submit/__init__.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +from datetime import datetime, timezone + +import click + +from kcidev.libs.common import kci_err, kci_msg, kci_warning +from kcidev.libs.git_repo import get_folder_repository +from kcidev.libs.kcidb import ( + build_build_payload, + build_checkout_payload, + build_submission_payload, + generate_build_id, + generate_checkout_id, + resolve_kcidb_config, + submit_to_kcidb, +) +from kcidev.subcommands.submit.options import ( + build_options, + checkout_options, + submit_common_options, +) + + +@click.group(help="Submit data to KCIDB") +@click.pass_context +def submit(ctx): + """Commands related to submitting data to KCIDB.""" + cfg = ctx.obj.get("CFG") + if cfg: + instance = ctx.obj.get("INSTANCE") + if not instance: + instance = cfg.get("default_instance") + ctx.obj["INSTANCE"] = instance + + +@submit.command() +@submit_common_options +@checkout_options +@build_options +@click.option( + "--from-json", + "from_json", + type=click.Path(exists=True), + help="Path to a JSON file with the complete KCIDB payload", +) +@click.pass_context +def build( + ctx, + origin, + kcidb_rest_url, + kcidb_token, + dry_run, + git_folder, + giturl, + branch, + commit, + tree_name, + patchset_hash, + arch, + config_name, + compiler, + status, + start_time, + duration, + log_url, + config_url, + comment, + command, + from_json, +): + """Submit build results to KCIDB.""" + cfg = ctx.obj.get("CFG") + instance = ctx.obj.get("INSTANCE") + + # --from-json mode + if from_json: + if git_folder: + kci_err("--from-json and --git-folder are mutually exclusive") + raise click.Abort() + payload = _load_json_payload(from_json, origin) + if dry_run: + kci_msg(json.dumps(payload, indent=2, sort_keys=True)) + return + rest_url, token = resolve_kcidb_config( + cfg, instance, kcidb_rest_url, kcidb_token + ) + result = submit_to_kcidb(rest_url, token, payload) + kci_msg(f"Submitted successfully: {result}") + return + + # Require --origin for non-JSON mode + if not origin: + kci_err("--origin is required (or use --from-json)") + raise click.Abort() + + # --git-folder auto-detection + if git_folder: + detected_url, detected_branch, detected_commit = get_folder_repository( + git_folder, branch + ) + if not giturl: + giturl = detected_url + if not branch: + branch = detected_branch + if not commit: + commit = detected_commit + + # Validate minimum required fields for ID generation + if not giturl or not commit: + kci_err( + "--giturl and --commit are required (or use --git-folder or --from-json)" + ) + raise click.Abort() + + if not branch: + branch = "" + + if patchset_hash is None: + patchset_hash = "" + + # Default start_time to current UTC + if not start_time: + start_time = datetime.now(timezone.utc).isoformat() + + # Generate IDs + checkout_id = generate_checkout_id(origin, giturl, branch, commit, patchset_hash) + build_id = generate_build_id( + origin, checkout_id, arch or "", config_name or "", compiler or "", start_time + ) + + # Build payload + checkout = build_checkout_payload( + origin, + checkout_id, + tree_name=tree_name, + git_repository_url=giturl, + git_repository_branch=branch if branch else None, + git_commit_hash=commit, + patchset_hash=patchset_hash if patchset_hash else None, + ) + + build_obj = build_build_payload( + origin, + build_id, + checkout_id, + start_time=start_time, + duration=duration, + architecture=arch, + compiler=compiler, + config_name=config_name, + config_url=config_url, + log_url=log_url, + comment=comment, + command=command, + status=status, + ) + + payload = build_submission_payload([checkout], [build_obj]) + + if dry_run: + kci_msg(json.dumps(payload, indent=2, sort_keys=True)) + return + + # Submit + rest_url, token = resolve_kcidb_config(cfg, instance, kcidb_rest_url, kcidb_token) + result = submit_to_kcidb(rest_url, token, payload) + kci_msg(f"Submitted successfully: {result}") + + +def _load_json_payload(path, origin_override): + """Load and validate a JSON payload file.""" + try: + with open(path, "r") as f: + payload = json.load(f) + except (json.JSONDecodeError, OSError) as e: + kci_err(f"Failed to read JSON file: {e}") + raise click.Abort() + + if "version" not in payload: + kci_err("JSON payload must contain a 'version' key") + raise click.Abort() + + if "checkouts" not in payload and "builds" not in payload: + kci_err("JSON payload must contain at least 'checkouts' or 'builds'") + raise click.Abort() + + # Override origin if specified + if origin_override: + for obj_list_key in ("checkouts", "builds", "tests"): + if obj_list_key in payload: + for obj in payload[obj_list_key]: + obj["origin"] = origin_override + + return payload + + +if __name__ == "__main__": + submit() diff --git a/kcidev/subcommands/submit/options.py b/kcidev/subcommands/submit/options.py new file mode 100644 index 0000000..7d9cfa4 --- /dev/null +++ b/kcidev/subcommands/submit/options.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from functools import wraps + +import click + + +def submit_common_options(func): + @click.option( + "--origin", + help="KCIDB origin name (CI system identifier)", + ) + @click.option( + "--kcidb-rest-url", + help="KCIDB REST API URL (overrides config and env)", + ) + @click.option( + "--kcidb-token", + help="KCIDB auth token (overrides config and env)", + ) + @click.option( + "--dry-run", + is_flag=True, + help="Print JSON payload without submitting", + ) + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def checkout_options(func): + @click.option( + "--git-folder", + type=click.Path(exists=True), + help="Path to git repo; auto-detects giturl, branch, commit", + ) + @click.option( + "--giturl", + help="Git repository URL", + ) + @click.option( + "--branch", + help="Git branch name", + ) + @click.option( + "--commit", + help="Full git commit hash", + ) + @click.option( + "--tree-name", + help="Tree name for dashboard grouping", + ) + @click.option( + "--patchset-hash", + default=None, + help="SHA256 patchset hash (empty string means no patches)", + ) + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def build_options(func): + @click.option( + "--arch", + help="Target build architecture (e.g. x86_64)", + ) + @click.option( + "--config-name", + help="Build configuration name (e.g. defconfig)", + ) + @click.option( + "--compiler", + help="Compiler name and version (e.g. gcc-14)", + ) + @click.option( + "--status", + type=click.Choice( + ["PASS", "FAIL", "ERROR", "MISS", "DONE", "SKIP"], case_sensitive=True + ), + help="Build status", + ) + @click.option( + "--start-time", + help="RFC3339 build start time (defaults to current UTC)", + ) + @click.option( + "--duration", + type=click.FLOAT, + help="Build duration in seconds", + ) + @click.option( + "--log-url", + help="URL of the build log", + ) + @click.option( + "--config-url", + help="URL of the build config", + ) + @click.option( + "--comment", + help="Human-readable comment", + ) + @click.option( + "--command", + help="Shell command used for the build", + ) + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper diff --git a/tests/test_kcidb.py b/tests/test_kcidb.py new file mode 100644 index 0000000..1ab871e --- /dev/null +++ b/tests/test_kcidb.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import os + +import click +import pytest + +from kcidev.libs.kcidb import ( + build_build_payload, + build_checkout_payload, + build_submission_payload, + generate_build_id, + generate_checkout_id, + resolve_kcidb_config, +) + +# --------------------- +# ID generation tests +# --------------------- + + +class TestGenerateCheckoutId: + def test_invalid_json_argument(self): + with pytest.raises(json.JSONDecodeError): + # Invalid JSON must not be silently accepted as checkout input. + json.loads('{"origin": "myci", "repo_url": "https://repo"') + + def test_missing_required_argument(self): + # Missing the required `patchset_hash` is still allowed (has default), + # but omitting a required positional argument should raise. + with pytest.raises(TypeError): + generate_checkout_id("myci", "https://repo", "main") + + def test_deterministic(self): + id1 = generate_checkout_id( + "myci", "https://git.kernel.org/linux.git", "main", "abc123", "" + ) + id2 = generate_checkout_id( + "myci", "https://git.kernel.org/linux.git", "main", "abc123", "" + ) + assert id1 == id2 + + def test_format(self): + result = generate_checkout_id( + "myci", "https://git.kernel.org/linux.git", "main", "abc123", "" + ) + assert result.startswith("myci:ck-") + # origin:ck- + 64 hex chars + local_id = result.split(":")[1] + assert local_id.startswith("ck-") + assert len(local_id) == 3 + 64 + + def test_different_repos_produce_different_ids(self): + id1 = generate_checkout_id("myci", "https://repo1.git", "main", "abc", "") + id2 = generate_checkout_id("myci", "https://repo2.git", "main", "abc", "") + assert id1 != id2 + + def test_different_commits_produce_different_ids(self): + id1 = generate_checkout_id("myci", "https://repo.git", "main", "abc", "") + id2 = generate_checkout_id("myci", "https://repo.git", "main", "def", "") + assert id1 != id2 + + def test_different_patchsets_produce_different_ids(self): + id1 = generate_checkout_id("myci", "https://repo.git", "main", "abc", "") + id2 = generate_checkout_id("myci", "https://repo.git", "main", "abc", "patch1") + assert id1 != id2 + + def test_different_origins_produce_different_ids(self): + id1 = generate_checkout_id("ci1", "https://repo.git", "main", "abc", "") + id2 = generate_checkout_id("ci2", "https://repo.git", "main", "abc", "") + assert id1 != id2 + + +class TestGenerateBuildId: + def test_deterministic(self): + id1 = generate_build_id( + "myci", + "myci:ck-abc", + "x86_64", + "defconfig", + "gcc-12", + "2024-01-01T00:00:00+00:00", + ) + id2 = generate_build_id( + "myci", + "myci:ck-abc", + "x86_64", + "defconfig", + "gcc-12", + "2024-01-01T00:00:00+00:00", + ) + assert id1 == id2 + + def test_format(self): + result = generate_build_id( + "myci", + "myci:ck-abc", + "x86_64", + "defconfig", + "gcc-12", + "2024-01-01T00:00:00+00:00", + ) + assert result.startswith("myci:b-") + local_id = result.split(":")[1] + assert local_id.startswith("b-") + assert len(local_id) == 2 + 64 + + def test_different_arch_produce_different_ids(self): + id1 = generate_build_id("myci", "ck1", "x86_64", "defconfig", "gcc", "t1") + id2 = generate_build_id("myci", "ck1", "arm64", "defconfig", "gcc", "t1") + assert id1 != id2 + + +# --------------------- +# Payload building tests +# --------------------- + + +class TestBuildCheckoutPayload: + def test_minimal(self): + result = build_checkout_payload("myci", "myci:ck-1") + assert result == {"id": "myci:ck-1", "origin": "myci"} + + def test_with_optional_fields(self): + result = build_checkout_payload( + "myci", + "myci:ck-1", + git_repository_url="https://repo.git", + git_commit_hash="abc123", + tree_name="mainline", + ) + assert result["git_repository_url"] == "https://repo.git" + assert result["git_commit_hash"] == "abc123" + assert result["tree_name"] == "mainline" + + def test_none_fields_excluded(self): + result = build_checkout_payload( + "myci", + "myci:ck-1", + git_repository_url="https://repo.git", + tree_name=None, + ) + assert "tree_name" not in result + assert "git_repository_url" in result + + +class TestBuildBuildPayload: + def test_minimal(self): + result = build_build_payload("myci", "myci:b-1", "myci:ck-1") + assert result == { + "id": "myci:b-1", + "origin": "myci", + "checkout_id": "myci:ck-1", + } + + def test_with_optional_fields(self): + result = build_build_payload( + "myci", + "myci:b-1", + "myci:ck-1", + architecture="x86_64", + status="PASS", + duration=120.5, + ) + assert result["architecture"] == "x86_64" + assert result["status"] == "PASS" + assert result["duration"] == 120.5 + + def test_none_fields_excluded(self): + result = build_build_payload( + "myci", + "myci:b-1", + "myci:ck-1", + architecture="x86_64", + duration=None, + ) + assert "duration" not in result + assert result["architecture"] == "x86_64" + + +class TestBuildSubmissionPayload: + def test_complete(self): + checkouts = [{"id": "x:1", "origin": "x"}] + builds = [{"id": "x:2", "origin": "x", "checkout_id": "x:1"}] + result = build_submission_payload(checkouts, builds) + assert result["version"] == {"major": 5, "minor": 3} + assert len(result["checkouts"]) == 1 + assert len(result["builds"]) == 1 + + def test_empty_lists_excluded(self): + result = build_submission_payload([], []) + assert "checkouts" not in result + assert "builds" not in result + assert "version" in result + + +# --------------------- +# Config resolution tests +# --------------------- + + +class TestResolveKcidbConfig: + def test_cli_flags_take_priority(self): + url, token = resolve_kcidb_config(None, None, "https://host/submit", "mytoken") + assert url == "https://host/submit" + assert token == "mytoken" + + def test_env_var(self, monkeypatch): + monkeypatch.setenv("KCIDB_REST", "https://mytoken@kcidb.example.com/") + url, token = resolve_kcidb_config(None, None, None, None) + assert "kcidb.example.com" in url + assert token == "mytoken" + + def test_env_var_appends_submit(self, monkeypatch): + monkeypatch.setenv("KCIDB_REST", "https://mytoken@kcidb.example.com/") + url, token = resolve_kcidb_config(None, None, None, None) + assert url.endswith("/submit") + + def test_instance_config(self): + cfg = { + "staging": { + "kcidb_rest_url": "https://host/submit", + "kcidb_token": "tok123", + } + } + url, token = resolve_kcidb_config(cfg, "staging", None, None) + assert url == "https://host/submit" + assert token == "tok123" + + def test_no_credentials_aborts(self, monkeypatch): + monkeypatch.delenv("KCIDB_REST", raising=False) + with pytest.raises(click.exceptions.Abort): + resolve_kcidb_config(None, None, None, None) + + def test_cli_overrides_env(self, monkeypatch): + monkeypatch.setenv("KCIDB_REST", "https://envtoken@host.com/") + url, token = resolve_kcidb_config( + None, None, "https://cli-host/submit", "cli-token" + ) + assert url == "https://cli-host/submit" + assert token == "cli-token" diff --git a/tests/test_kcidev.py b/tests/test_kcidev.py index 1a5baf5..8a69e86 100644 --- a/tests/test_kcidev.py +++ b/tests/test_kcidev.py @@ -931,6 +931,28 @@ def test_kcidev_version_from_metadata(): assert re.match(r"^\d+\.\d+\.\d+", kcidev_version) +def test_kcidev_submit_help(): + command = ["poetry", "run", "kci-dev", "submit", "--help"] + result = run(command, stdout=PIPE, stderr=PIPE, universal_newlines=True) + print("returncode: " + str(result.returncode)) + print("#### stdout ####") + print(result.stdout) + print("#### stderr ####") + print(result.stderr) + assert result.returncode == 0 + + +def test_kcidev_submit_build_help(): + command = ["poetry", "run", "kci-dev", "submit", "build", "--help"] + result = run(command, stdout=PIPE, stderr=PIPE, universal_newlines=True) + print("returncode: " + str(result.returncode)) + print("#### stdout ####") + print(result.stdout) + print("#### stderr ####") + print(result.stderr) + assert result.returncode == 0 + + def test_clean(): # clean enviroment shutil.rmtree("my-new-repo/")