Skip to content

Commit 660e9e7

Browse files
committed
subcommand: add new subsystem/subcommand - storage
Add new subcommand to use KernelCI storage. Useful to store logs, artifacts and etc. Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent 9349ab8 commit 660e9e7

5 files changed

Lines changed: 244 additions & 3 deletions

File tree

kcidev/_data/kci-dev.toml.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ api="https://staging.kernelci.org:9000/"
1111
token="example"
1212
kcidb_rest_url="https://staging.kcidb.kernelci.org/submit"
1313
kcidb_token="your-kcidb-token-here"
14+
storage_url="https://files-staging.kernelci.org"
15+
storage_token="your-storage-jwt-token"
1416

1517
[production]
1618
pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/"
1719
api="https://kernelci-api.westus3.cloudapp.azure.com/"
1820
token="example"
1921
kcidb_rest_url="https://db.kernelci.org/submit"
20-
kcidb_token="your-kcidb-token-here"
22+
kcidb_token="your-kcidb-token-here"
23+
storage_url="https://files.kernelci.org"
24+
storage_token="your-storage-jwt-token"

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 not in ("config", "results", "submit"):
81+
if subcommand not in ("config", "results", "submit", "storage"):
8282
if not config:
8383
logging.warning(f"No config file found for subcommand {subcommand}")
8484
kci_err(

kcidev/libs/storage.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import logging
5+
import os
6+
7+
import click
8+
import requests
9+
10+
from kcidev.libs.common import kci_err, kci_msg, kcidev_session
11+
12+
13+
def resolve_storage_config(cfg, instance, cli_url, cli_token):
14+
"""
15+
Resolve storage URL and token. Priority:
16+
1. CLI flags (--storage-url, --storage-token)
17+
2. Environment variables (KCI_STORAGE_URL, KCI_STORAGE_TOKEN)
18+
3. Instance config (storage_url, storage_token in TOML)
19+
20+
Returns (storage_url, token) tuple.
21+
Raises click.Abort if no valid credentials found.
22+
"""
23+
# 1. CLI flags
24+
if cli_url or cli_token:
25+
if not cli_url or not cli_token:
26+
kci_err("Both --storage-url and --storage-token must be provided together")
27+
raise click.Abort()
28+
logging.debug(f"Using storage config from CLI flags: {cli_url}")
29+
return cli_url, cli_token
30+
31+
# 2. Environment variables
32+
env_url = os.environ.get("KCI_STORAGE_URL")
33+
env_token = os.environ.get("KCI_STORAGE_TOKEN")
34+
if env_url or env_token:
35+
if env_url and env_token:
36+
logging.debug(f"Using storage config from env vars: {env_url}")
37+
return env_url, env_token
38+
kci_err(
39+
"Both KCI_STORAGE_URL and KCI_STORAGE_TOKEN env vars must be set together"
40+
)
41+
raise click.Abort()
42+
43+
# 3. Instance config
44+
if cfg and instance and instance in cfg:
45+
inst_cfg = cfg[instance]
46+
storage_url = inst_cfg.get("storage_url")
47+
storage_token = inst_cfg.get("storage_token")
48+
if storage_url and storage_token:
49+
logging.debug(
50+
f"Using storage config from instance '{instance}': {storage_url}"
51+
)
52+
return storage_url, storage_token
53+
54+
kci_err(
55+
"No storage credentials found. Provide --storage-url and --storage-token, "
56+
"set KCI_STORAGE_URL/KCI_STORAGE_TOKEN env vars, "
57+
"or configure storage_url/storage_token in config file"
58+
)
59+
raise click.Abort()
60+
61+
62+
def upload_file(storage_url, token, remote_path, local_file_path, timeout=120):
63+
"""
64+
Upload a file to kernelci-storage.
65+
66+
POST /v1/file with multipart form:
67+
- path: remote directory path
68+
- file0: file content
69+
"""
70+
url = storage_url.rstrip("/") + "/v1/file"
71+
headers = {
72+
"Authorization": f"Bearer {token}",
73+
}
74+
75+
logging.info(f"Uploading {local_file_path} to {remote_path}/")
76+
logging.debug(f"POST request to: {url}")
77+
78+
try:
79+
with open(local_file_path, "rb") as f:
80+
files = {"file0": (os.path.basename(local_file_path), f)}
81+
data = {"path": remote_path}
82+
response = kcidev_session.post(
83+
url, headers=headers, files=files, data=data, timeout=timeout
84+
)
85+
logging.debug(f"Upload response status: {response.status_code}")
86+
except requests.exceptions.RequestException as e:
87+
logging.error(f"Upload request failed: {e}")
88+
kci_err(f"Storage connection error: {e}")
89+
raise click.Abort()
90+
91+
if response.status_code == 200:
92+
logging.info(f"Upload successful: {os.path.basename(local_file_path)}")
93+
return response.text
94+
elif response.status_code == 401:
95+
kci_err("Authentication failed: invalid or expired token")
96+
raise click.Abort()
97+
else:
98+
kci_err(f"Upload failed (HTTP {response.status_code}): {response.text}")
99+
raise click.Abort()
100+
101+
102+
def check_auth(storage_url, token, timeout=30):
103+
"""
104+
Validate a JWT token against the storage server.
105+
106+
GET /v1/checkauth
107+
Returns the response text on success.
108+
"""
109+
url = storage_url.rstrip("/") + "/v1/checkauth"
110+
headers = {
111+
"Authorization": f"Bearer {token}",
112+
}
113+
114+
logging.info("Checking storage authentication")
115+
logging.debug(f"GET request to: {url}")
116+
117+
try:
118+
response = kcidev_session.get(url, headers=headers, timeout=timeout)
119+
logging.debug(f"Auth check response status: {response.status_code}")
120+
except requests.exceptions.RequestException as e:
121+
logging.error(f"Auth check request failed: {e}")
122+
kci_err(f"Storage connection error: {e}")
123+
raise click.Abort()
124+
125+
if response.status_code == 200:
126+
logging.info(f"Authentication valid: {response.text}")
127+
return response.text
128+
elif response.status_code == 401:
129+
kci_err("Authentication failed: invalid or expired token")
130+
raise click.Abort()
131+
else:
132+
kci_err(f"Auth check failed (HTTP {response.status_code}): {response.text}")
133+
raise click.Abort()

kcidev/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
config,
1414
maestro,
1515
results,
16+
storage,
1617
submit,
1718
testretry,
1819
watch,
@@ -44,7 +45,7 @@ def cli(ctx, settings, instance, debug):
4445
if subcommand not in ("results", "config"):
4546
if instance:
4647
ctx.obj["INSTANCE"] = instance
47-
elif subcommand != "submit":
48+
elif subcommand not in ("submit", "storage"):
4849
ctx.obj["INSTANCE"] = ctx.obj["CFG"].get("default_instance")
4950
fconfig = config_path(settings)
5051
if not ctx.obj["INSTANCE"]:
@@ -63,6 +64,7 @@ def run():
6364
cli.add_command(maestro.maestro)
6465
cli.add_command(testretry.testretry)
6566
cli.add_command(results.results)
67+
cli.add_command(storage.storage)
6668
cli.add_command(submit.submit)
6769
cli.add_command(watch.watch)
6870
cli()

kcidev/subcommands/storage.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import os
5+
import sys
6+
7+
import click
8+
9+
from kcidev.libs.common import kci_err, kci_msg
10+
from kcidev.libs.storage import check_auth, resolve_storage_config, upload_file
11+
12+
13+
@click.group(
14+
help="""Interact with KernelCI storage.
15+
16+
This command group provides access to KernelCI storage operations including
17+
uploading files and validating authentication tokens.
18+
19+
\b
20+
Examples:
21+
# Upload a file (path recommended to match your origin)
22+
kci-dev storage upload --path myci/build-123 ./some-files.tar.xz
23+
# Upload multiple files
24+
kci-dev storage upload --path myci/build-123 log.txt config.gz
25+
# Validate authentication token
26+
kci-dev storage checkauth
27+
""",
28+
invoke_without_command=True,
29+
)
30+
@click.pass_context
31+
def storage(ctx):
32+
"""Commands related to KernelCI storage."""
33+
cfg = ctx.obj.get("CFG")
34+
if cfg:
35+
instance = ctx.obj.get("INSTANCE")
36+
if not instance:
37+
instance = cfg.get("default_instance")
38+
ctx.obj["INSTANCE"] = instance
39+
40+
if ctx.invoked_subcommand is None:
41+
click.echo(ctx.get_help())
42+
sys.exit(0)
43+
44+
45+
@storage.command()
46+
@click.option(
47+
"--storage-url",
48+
help="Storage server URL (overrides config and env)",
49+
)
50+
@click.option(
51+
"--storage-token",
52+
help="Storage JWT token (overrides config and env)",
53+
)
54+
@click.option(
55+
"--path",
56+
"remote_path",
57+
required=True,
58+
help="Remote directory path, recommended to match your origin (e.g. origin/build-123)",
59+
)
60+
@click.argument(
61+
"files",
62+
nargs=-1,
63+
required=True,
64+
type=click.Path(exists=True),
65+
)
66+
@click.pass_context
67+
def upload(ctx, storage_url, storage_token, remote_path, files):
68+
"""Upload files to KernelCI storage."""
69+
cfg = ctx.obj.get("CFG")
70+
instance = ctx.obj.get("INSTANCE")
71+
72+
url, token = resolve_storage_config(cfg, instance, storage_url, storage_token)
73+
74+
for file_path in files:
75+
if os.path.isdir(file_path):
76+
kci_err(f"Skipping directory: {file_path}")
77+
continue
78+
filename = os.path.basename(file_path)
79+
kci_msg(f"Uploading {filename} to {remote_path}/...")
80+
result = upload_file(url, token, remote_path, file_path)
81+
kci_msg(f"Uploaded: {filename} - {result}")
82+
83+
84+
@storage.command()
85+
@click.option(
86+
"--storage-url",
87+
help="Storage server URL (overrides config and env)",
88+
)
89+
@click.option(
90+
"--storage-token",
91+
help="Storage JWT token (overrides config and env)",
92+
)
93+
@click.pass_context
94+
def checkauth(ctx, storage_url, storage_token):
95+
"""Validate storage authentication token."""
96+
cfg = ctx.obj.get("CFG")
97+
instance = ctx.obj.get("INSTANCE")
98+
99+
url, token = resolve_storage_config(cfg, instance, storage_url, storage_token)
100+
101+
result = check_auth(url, token)
102+
kci_msg(result)

0 commit comments

Comments
 (0)