Skip to content

Commit 77a073e

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 77a073e

8 files changed

Lines changed: 797 additions & 12 deletions

File tree

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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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+
field_map = {
56+
"tree_name": "tree_name",
57+
"git_repository_url": "git_repository_url",
58+
"git_repository_branch": "git_repository_branch",
59+
"git_commit_hash": "git_commit_hash",
60+
"patchset_hash": "patchset_hash",
61+
"start_time": "start_time",
62+
"valid": "valid",
63+
}
64+
for json_key, kwarg_key in field_map.items():
65+
val = kwargs.get(kwarg_key)
66+
if val is not None:
67+
checkout[json_key] = val
68+
return checkout
69+
70+
71+
def build_build_payload(origin, build_id, checkout_id, **kwargs):
72+
"""Build a build object for the KCIDB JSON payload."""
73+
build = {
74+
"id": build_id,
75+
"origin": origin,
76+
"checkout_id": checkout_id,
77+
}
78+
field_map = {
79+
"start_time": "start_time",
80+
"duration": "duration",
81+
"architecture": "architecture",
82+
"compiler": "compiler",
83+
"config_name": "config_name",
84+
"config_url": "config_url",
85+
"log_url": "log_url",
86+
"comment": "comment",
87+
"command": "command",
88+
"status": "status",
89+
}
90+
for json_key, kwarg_key in field_map.items():
91+
val = kwargs.get(kwarg_key)
92+
if val is not None:
93+
build[json_key] = val
94+
return build
95+
96+
97+
def build_submission_payload(checkouts, builds):
98+
"""Build the complete KCIDB v5.3 submission JSON."""
99+
payload = {
100+
"version": {"major": KCIDB_SCHEMA_MAJOR, "minor": KCIDB_SCHEMA_MINOR},
101+
}
102+
if checkouts:
103+
payload["checkouts"] = checkouts
104+
if builds:
105+
payload["builds"] = builds
106+
return payload
107+
108+
109+
# ---------------------
110+
# Config resolution
111+
# ---------------------
112+
113+
114+
def _parse_kcidb_rest_env(env_value):
115+
"""
116+
Parse KCIDB_REST env var.
117+
Format: https://token@host[:port][/path]
118+
Returns (url, token) or (None, None) on failure.
119+
"""
120+
parsed = urllib.parse.urlparse(env_value)
121+
token = parsed.username
122+
if not token:
123+
return None, None
124+
125+
# Rebuild URL without credentials
126+
host = parsed.hostname
127+
if parsed.port:
128+
host = f"{host}:{parsed.port}"
129+
path = parsed.path if parsed.path else "/"
130+
if not path.endswith("/submit"):
131+
path = path.rstrip("/") + "/submit"
132+
133+
clean_url = urllib.parse.urlunparse(
134+
(parsed.scheme, host, path, parsed.params, parsed.query, parsed.fragment)
135+
)
136+
return clean_url, token
137+
138+
139+
def resolve_kcidb_config(cfg, instance, cli_rest_url, cli_token):
140+
"""
141+
Resolve KCIDB REST URL and token. Priority:
142+
1. CLI flags (--kcidb-rest-url, --kcidb-token)
143+
2. KCIDB_REST environment variable
144+
3. Instance config (kcidb_rest_url, kcidb_token in TOML)
145+
146+
Returns (rest_url, token) tuple.
147+
Raises click.Abort if no valid credentials found.
148+
"""
149+
# 1. CLI flags
150+
if cli_rest_url and cli_token:
151+
return cli_rest_url, cli_token
152+
153+
# 2. Environment variable
154+
kcidb_rest_env = os.environ.get("KCIDB_REST")
155+
if kcidb_rest_env:
156+
url, token = _parse_kcidb_rest_env(kcidb_rest_env)
157+
if url and token:
158+
return url, token
159+
kci_warning("KCIDB_REST env var set but could not parse token from it")
160+
161+
# 3. Instance config
162+
if cfg and instance and instance in cfg:
163+
inst_cfg = cfg[instance]
164+
rest_url = inst_cfg.get("kcidb_rest_url")
165+
token = inst_cfg.get("kcidb_token")
166+
if rest_url and token:
167+
return rest_url, token
168+
169+
kci_err(
170+
"No KCIDB credentials found. Provide --kcidb-rest-url and --kcidb-token, "
171+
"set KCIDB_REST env var, or configure kcidb_rest_url/kcidb_token in config file"
172+
)
173+
raise click.Abort()
174+
175+
176+
# ---------------------
177+
# REST submission
178+
# ---------------------
179+
180+
181+
def submit_to_kcidb(rest_url, token, payload, timeout=60):
182+
"""
183+
POST the JSON payload to the KCIDB REST API.
184+
185+
Returns the response text on success.
186+
Raises click.Abort on failure.
187+
"""
188+
headers = {
189+
"Content-Type": "application/json",
190+
"Authorization": f"Bearer {token}",
191+
}
192+
try:
193+
response = kcidev_session.post(
194+
rest_url, json=payload, headers=headers, timeout=timeout
195+
)
196+
except Exception as e:
197+
kci_err(f"KCIDB API connection error: {e}")
198+
raise click.Abort()
199+
200+
if response.status_code == 200:
201+
return response.text
202+
else:
203+
kci_err(f"KCIDB submission failed: HTTP {response.status_code}")
204+
try:
205+
kci_err(response.json())
206+
except Exception:
207+
kci_err(response.text)
208+
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)