Skip to content

Commit bcdfe5e

Browse files
committed
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 <denys.f@collabora.com>
1 parent 7d6c2fd commit bcdfe5e

9 files changed

Lines changed: 813 additions & 14 deletions

File tree

docs/config_file.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@ token="example"
2929
pipeline="https://staging.kernelci.org:9100/"
3030
api="https://staging.kernelci.org:9000/"
3131
token="example"
32+
kcidb_rest_url="https://db.kernelci.org/submit"
33+
kcidb_token="your-kcidb-token-here"
3234

3335
[production]
3436
pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/"
3537
api="https://kernelci-api.westus3.cloudapp.azure.com/"
3638
token="example"
39+
kcidb_rest_url="https://staging.kcidb.kernelci.org/submit"
40+
kcidb_token="your-kcidb-token-here"
3741
```
3842

3943
Where `default_instance` is the default instance to use, if not provided in the command line.
4044
In section `local`, `staging`, `production` you can provide the host for the pipeline, api and also a token for the available instances.
41-
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.
42-
If you are using KernelCI Pipeline instance, you can get the token from the project maintainers.
45+
`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.
46+
If you are using KernelCI instances of pipeline or/and KCIDB, you can get the token from the KernelCI project maintainers.
4347
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.
4448

kcidev/_data/kci-dev.toml.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ token="example"
99
pipeline="https://staging.kernelci.org:9100/"
1010
api="https://staging.kernelci.org:9000/"
1111
token="example"
12+
kcidb_rest_url="https://staging.kcidb.kernelci.org/submit"
13+
kcidb_token="your-kcidb-token-here"
1214

1315
[production]
1416
pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/"
1517
api="https://kernelci-api.westus3.cloudapp.azure.com/"
16-
token="example"
18+
token="example"
19+
kcidb_rest_url="https://db.kernelci.org/submit"
20+
kcidb_token="your-kcidb-token-here"

kcidev/libs/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def load_toml(settings, subcommand):
7878
return config
7979

8080
# config and results subcommand work without a config file
81-
if subcommand != "config" and subcommand != "results":
81+
if subcommand not in ("config", "results", "submit"):
8282
if not config:
8383
logging.warning(f"No config file found for subcommand {subcommand}")
8484
kci_err(

kcidev/libs/kcidb.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import hashlib
5+
import json
6+
import os
7+
import urllib.parse
8+
9+
import click
10+
11+
from kcidev.libs.common import kci_err, kci_msg, kci_warning, kcidev_session
12+
13+
KCIDB_SCHEMA_MAJOR = 5
14+
KCIDB_SCHEMA_MINOR = 3
15+
16+
17+
# ---------------------
18+
# ID generation
19+
# ---------------------
20+
21+
22+
def _sha256_hex(data):
23+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
24+
25+
26+
def generate_checkout_id(origin, repo_url, branch, commit, patchset_hash=""):
27+
"""
28+
Deterministic checkout ID from source identity.
29+
Format: origin:ck-<sha256(repo_url|branch|commit|patchset_hash)>
30+
"""
31+
seed = f"{repo_url}|{branch}|{commit}|{patchset_hash}"
32+
return f"{origin}:ck-{_sha256_hex(seed)}"
33+
34+
35+
def generate_build_id(origin, checkout_id, arch, config_name, compiler, start_time):
36+
"""
37+
Deterministic build ID from build parameters.
38+
Format: origin:b-<sha256(checkout_id|arch|config_name|compiler|start_time)>
39+
"""
40+
seed = f"{checkout_id}|{arch}|{config_name}|{compiler}|{start_time}"
41+
return f"{origin}:b-{_sha256_hex(seed)}"
42+
43+
44+
# ---------------------
45+
# Payload building
46+
# ---------------------
47+
48+
49+
def build_checkout_payload(origin, checkout_id, **kwargs):
50+
"""Build a checkout object for the KCIDB JSON payload."""
51+
checkout = {
52+
"id": checkout_id,
53+
"origin": origin,
54+
}
55+
for key in [
56+
"tree_name",
57+
"git_repository_url",
58+
"git_repository_branch",
59+
"git_commit_hash",
60+
"patchset_hash",
61+
"start_time",
62+
"valid",
63+
]:
64+
if kwargs.get(key) is not None:
65+
checkout[key] = kwargs[key]
66+
return checkout
67+
68+
69+
def build_build_payload(origin, build_id, checkout_id, **kwargs):
70+
"""Build a build object for the KCIDB JSON payload."""
71+
build = {
72+
"id": build_id,
73+
"origin": origin,
74+
"checkout_id": checkout_id,
75+
}
76+
for key in [
77+
"start_time",
78+
"duration",
79+
"architecture",
80+
"compiler",
81+
"config_name",
82+
"config_url",
83+
"log_url",
84+
"comment",
85+
"command",
86+
"status",
87+
]:
88+
if kwargs.get(key) is not None:
89+
build[key] = kwargs[key]
90+
return build
91+
92+
93+
def build_submission_payload(checkouts, builds):
94+
"""Build the complete KCIDB v5.3 submission JSON."""
95+
payload = {
96+
"version": {"major": KCIDB_SCHEMA_MAJOR, "minor": KCIDB_SCHEMA_MINOR},
97+
}
98+
if checkouts:
99+
payload["checkouts"] = checkouts
100+
if builds:
101+
payload["builds"] = builds
102+
return payload
103+
104+
105+
# ---------------------
106+
# Config resolution
107+
# ---------------------
108+
109+
110+
def _parse_kcidb_rest_env(env_value):
111+
"""
112+
Parse KCIDB_REST env var.
113+
Format: https://token@host[:port][/path]
114+
Returns (url, token) or (None, None) on failure.
115+
"""
116+
parsed = urllib.parse.urlparse(env_value)
117+
token = parsed.username
118+
if not token:
119+
return None, None
120+
121+
# Rebuild URL without credentials
122+
host = parsed.hostname
123+
if parsed.port:
124+
host = f"{host}:{parsed.port}"
125+
path = parsed.path if parsed.path else "/"
126+
if not path.endswith("/submit"):
127+
path = path.rstrip("/") + "/submit"
128+
129+
clean_url = urllib.parse.urlunparse(
130+
(parsed.scheme, host, path, parsed.params, parsed.query, parsed.fragment)
131+
)
132+
return clean_url, token
133+
134+
135+
def resolve_kcidb_config(cfg, instance, cli_rest_url, cli_token):
136+
"""
137+
Resolve KCIDB REST URL and token. Priority:
138+
1. CLI flags (--kcidb-rest-url, --kcidb-token)
139+
2. KCIDB_REST environment variable
140+
3. Instance config (kcidb_rest_url, kcidb_token in TOML)
141+
142+
Returns (rest_url, token) tuple.
143+
Raises click.Abort if no valid credentials found.
144+
"""
145+
# 1. CLI flags
146+
if cli_rest_url or cli_token:
147+
if not cli_rest_url or not cli_token:
148+
kci_err("Both --kcidb-rest-url and --kcidb-token must be provided together")
149+
raise click.Abort()
150+
return cli_rest_url, cli_token
151+
152+
# 2. Environment variable
153+
kcidb_rest_env = os.environ.get("KCIDB_REST")
154+
if kcidb_rest_env:
155+
url, token = _parse_kcidb_rest_env(kcidb_rest_env)
156+
if url and token:
157+
return url, token
158+
kci_err("KCIDB_REST env var set but could not parse token from it")
159+
160+
# 3. Instance config
161+
if cfg and instance and instance in cfg:
162+
inst_cfg = cfg[instance]
163+
rest_url = inst_cfg.get("kcidb_rest_url")
164+
token = inst_cfg.get("kcidb_token")
165+
if rest_url and token:
166+
return rest_url, token
167+
168+
kci_err(
169+
"No KCIDB credentials found. Provide --kcidb-rest-url and --kcidb-token, "
170+
"set KCIDB_REST env var, or configure kcidb_rest_url/kcidb_token in config file"
171+
)
172+
raise click.Abort()
173+
174+
175+
# ---------------------
176+
# REST submission
177+
# ---------------------
178+
179+
180+
def submit_to_kcidb(rest_url, token, payload, timeout=60):
181+
"""
182+
POST the JSON payload to the KCIDB REST API.
183+
184+
Returns the response text on success.
185+
Raises click.Abort on failure.
186+
"""
187+
headers = {
188+
"Content-Type": "application/json",
189+
"Authorization": f"Bearer {token}",
190+
}
191+
try:
192+
response = kcidev_session.post(
193+
rest_url, json=payload, headers=headers, timeout=timeout
194+
)
195+
except Exception as e:
196+
kci_err(f"KCIDB API connection error: {e}")
197+
raise click.Abort()
198+
199+
if response.status_code < 300:
200+
try:
201+
return response.json()
202+
except Exception:
203+
return response.text
204+
else:
205+
kci_err(f"KCIDB submission failed: HTTP {response.status_code}")
206+
try:
207+
kci_err(response.json())
208+
except Exception:
209+
kci_err(response.text)
210+
raise click.Abort()

kcidev/main.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
config,
1414
maestro,
1515
results,
16+
submit,
1617
testretry,
1718
watch,
1819
)
@@ -40,19 +41,18 @@ def cli(ctx, settings, instance, debug):
4041
subcommand = ctx.invoked_subcommand
4142
ctx.obj = {"CFG": load_toml(settings, subcommand)}
4243
ctx.obj["SETTINGS"] = settings
43-
if subcommand != "results" and subcommand != "config":
44+
if subcommand not in ("results", "config"):
4445
if instance:
4546
ctx.obj["INSTANCE"] = instance
46-
else:
47+
elif subcommand != "submit":
4748
ctx.obj["INSTANCE"] = ctx.obj["CFG"].get("default_instance")
48-
fconfig = config_path(settings)
49-
if not ctx.obj["INSTANCE"]:
50-
kci_err(f"No instance defined in settings or as argument in {fconfig}")
51-
raise click.Abort()
52-
if ctx.obj["INSTANCE"] not in ctx.obj["CFG"]:
53-
kci_err(f"Instance {ctx.obj['INSTANCE']} not found in {fconfig}")
54-
raise click.Abort()
55-
pass
49+
fconfig = config_path(settings)
50+
if not ctx.obj["INSTANCE"]:
51+
kci_err(f"No instance defined in settings or as argument in {fconfig}")
52+
raise click.Abort()
53+
if ctx.obj["INSTANCE"] not in ctx.obj["CFG"]:
54+
kci_err(f"Instance {ctx.obj['INSTANCE']} not found in {fconfig}")
55+
raise click.Abort()
5656

5757

5858
def run():
@@ -63,6 +63,7 @@ def run():
6363
cli.add_command(maestro.maestro)
6464
cli.add_command(testretry.testretry)
6565
cli.add_command(results.results)
66+
cli.add_command(submit.submit)
6667
cli.add_command(watch.watch)
6768
cli()
6869

0 commit comments

Comments
 (0)