Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion kcidev/_data/kci-dev.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ api="https://staging.kernelci.org:9000/"
token="example"
kcidb_rest_url="https://staging.kcidb.kernelci.org/submit"
kcidb_token="your-kcidb-token-here"
storage_url="https://files-staging.kernelci.org"
storage_token="your-storage-jwt-token"

[production]
pipeline="https://kernelci-pipeline.westus3.cloudapp.azure.com/"
api="https://kernelci-api.westus3.cloudapp.azure.com/"
token="example"
kcidb_rest_url="https://db.kernelci.org/submit"
kcidb_token="your-kcidb-token-here"
kcidb_token="your-kcidb-token-here"
storage_url="https://files.kernelci.org"
storage_token="your-storage-jwt-token"
2 changes: 1 addition & 1 deletion kcidev/libs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def load_toml(settings, subcommand):
return config

# config and results subcommand work without a config file
if subcommand not in ("config", "results", "submit"):
if subcommand not in ("config", "results", "submit", "storage"):
if not config:
logging.warning(f"No config file found for subcommand {subcommand}")
kci_err(
Expand Down
133 changes: 133 additions & 0 deletions kcidev/libs/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging
import os

import click
import requests

from kcidev.libs.common import kci_err, kci_msg, kcidev_session


def resolve_storage_config(cfg, instance, cli_url, cli_token):
"""
Resolve storage URL and token. Priority:
1. CLI flags (--storage-url, --storage-token)
2. Environment variables (KCI_STORAGE_URL, KCI_STORAGE_TOKEN)
3. Instance config (storage_url, storage_token in TOML)

Returns (storage_url, token) tuple.
Raises click.Abort if no valid credentials found.
"""
# 1. CLI flags
if cli_url or cli_token:
if not cli_url or not cli_token:
kci_err("Both --storage-url and --storage-token must be provided together")
raise click.Abort()
logging.debug(f"Using storage config from CLI flags: {cli_url}")
return cli_url, cli_token

# 2. Environment variables
env_url = os.environ.get("KCI_STORAGE_URL")
env_token = os.environ.get("KCI_STORAGE_TOKEN")
if env_url or env_token:
if env_url and env_token:
logging.debug(f"Using storage config from env vars: {env_url}")
return env_url, env_token
kci_err(
"Both KCI_STORAGE_URL and KCI_STORAGE_TOKEN env vars must be set together"
)
raise click.Abort()

# 3. Instance config
if cfg and instance and instance in cfg:
inst_cfg = cfg[instance]
storage_url = inst_cfg.get("storage_url")
storage_token = inst_cfg.get("storage_token")
if storage_url and storage_token:
logging.debug(
f"Using storage config from instance '{instance}': {storage_url}"
)
return storage_url, storage_token

kci_err(
"No storage credentials found. Provide --storage-url and --storage-token, "
"set KCI_STORAGE_URL/KCI_STORAGE_TOKEN env vars, "
"or configure storage_url/storage_token in config file"
)
raise click.Abort()


def upload_file(storage_url, token, remote_path, local_file_path, timeout=120):
"""
Upload a file to kernelci-storage.

POST /v1/file with multipart form:
- path: remote directory path
- file0: file content
"""
url = storage_url.rstrip("/") + "/v1/file"
headers = {
"Authorization": f"Bearer {token}",
}

logging.info(f"Uploading {local_file_path} to {remote_path}/")
logging.debug(f"POST request to: {url}")

try:
with open(local_file_path, "rb") as f:
files = {"file0": (os.path.basename(local_file_path), f)}
data = {"path": remote_path}
response = kcidev_session.post(
url, headers=headers, files=files, data=data, timeout=timeout
)
logging.debug(f"Upload response status: {response.status_code}")
except requests.exceptions.RequestException as e:
logging.error(f"Upload request failed: {e}")
kci_err(f"Storage connection error: {e}")
raise click.Abort()

if response.status_code == 200:
logging.info(f"Upload successful: {os.path.basename(local_file_path)}")
return response.text
elif response.status_code == 401:
kci_err("Authentication failed: invalid or expired token")
raise click.Abort()
else:
kci_err(f"Upload failed (HTTP {response.status_code}): {response.text}")
raise click.Abort()


def check_auth(storage_url, token, timeout=30):
"""
Validate a JWT token against the storage server.

GET /v1/checkauth
Returns the response text on success.
"""
url = storage_url.rstrip("/") + "/v1/checkauth"
headers = {
"Authorization": f"Bearer {token}",
}

logging.info("Checking storage authentication")
logging.debug(f"GET request to: {url}")

try:
response = kcidev_session.get(url, headers=headers, timeout=timeout)
logging.debug(f"Auth check response status: {response.status_code}")
except requests.exceptions.RequestException as e:
logging.error(f"Auth check request failed: {e}")
kci_err(f"Storage connection error: {e}")
raise click.Abort()

if response.status_code == 200:
logging.info(f"Authentication valid: {response.text}")
return response.text
elif response.status_code == 401:
kci_err("Authentication failed: invalid or expired token")
raise click.Abort()
else:
kci_err(f"Auth check failed (HTTP {response.status_code}): {response.text}")
raise click.Abort()
4 changes: 3 additions & 1 deletion kcidev/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
config,
maestro,
results,
storage,
submit,
testretry,
watch,
Expand Down Expand Up @@ -44,7 +45,7 @@ def cli(ctx, settings, instance, debug):
if subcommand not in ("results", "config"):
if instance:
ctx.obj["INSTANCE"] = instance
elif subcommand != "submit":
elif subcommand not in ("submit", "storage"):
ctx.obj["INSTANCE"] = ctx.obj["CFG"].get("default_instance")
fconfig = config_path(settings)
if not ctx.obj["INSTANCE"]:
Expand All @@ -63,6 +64,7 @@ def run():
cli.add_command(maestro.maestro)
cli.add_command(testretry.testretry)
cli.add_command(results.results)
cli.add_command(storage.storage)
cli.add_command(submit.submit)
cli.add_command(watch.watch)
cli()
Expand Down
102 changes: 102 additions & 0 deletions kcidev/subcommands/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys

import click

from kcidev.libs.common import kci_err, kci_msg
from kcidev.libs.storage import check_auth, resolve_storage_config, upload_file


@click.group(
help="""Interact with KernelCI storage.

This command group provides access to KernelCI storage operations including
uploading files and validating authentication tokens.

\b
Examples:
# Upload a file (path recommended to match your origin)
kci-dev storage upload --path myci/build-123 ./some-files.tar.xz
# Upload multiple files
kci-dev storage upload --path myci/build-123 log.txt config.gz
# Validate authentication token
kci-dev storage checkauth
""",
invoke_without_command=True,
)
@click.pass_context
def storage(ctx):
"""Commands related to KernelCI storage."""
cfg = ctx.obj.get("CFG")
if cfg:
instance = ctx.obj.get("INSTANCE")
if not instance:
instance = cfg.get("default_instance")
ctx.obj["INSTANCE"] = instance

if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
sys.exit(0)


@storage.command()
@click.option(
"--storage-url",
help="Storage server URL (overrides config and env)",
)
@click.option(
"--storage-token",
help="Storage JWT token (overrides config and env)",
)
@click.option(
"--path",
"remote_path",
required=True,
help="Remote directory path, recommended to match your origin (e.g. origin/build-123)",
)
@click.argument(
"files",
nargs=-1,
required=True,
type=click.Path(exists=True),
)
@click.pass_context
def upload(ctx, storage_url, storage_token, remote_path, files):
"""Upload files to KernelCI storage."""
cfg = ctx.obj.get("CFG")
instance = ctx.obj.get("INSTANCE")

url, token = resolve_storage_config(cfg, instance, storage_url, storage_token)

for file_path in files:
if os.path.isdir(file_path):
kci_err(f"Skipping directory: {file_path}")
continue
filename = os.path.basename(file_path)
kci_msg(f"Uploading {filename} to {remote_path}/...")
result = upload_file(url, token, remote_path, file_path)
kci_msg(f"Uploaded: {filename} - {result}")


@storage.command()
@click.option(
"--storage-url",
help="Storage server URL (overrides config and env)",
)
@click.option(
"--storage-token",
help="Storage JWT token (overrides config and env)",
)
@click.pass_context
def checkauth(ctx, storage_url, storage_token):
"""Validate storage authentication token."""
cfg = ctx.obj.get("CFG")
instance = ctx.obj.get("INSTANCE")

url, token = resolve_storage_config(cfg, instance, storage_url, storage_token)

result = check_auth(url, token)
kci_msg(result)
Loading