J
2026-04-14 13:03:26 -04:00
|
|
|
"""Unified ``ria-app`` CLI.
|
|
|
|
|
|
|
|
|
|
Subcommands:
|
|
|
|
|
|
|
|
|
|
- ``ria-app pull <app>[:tag]`` — pull a RIA app image from the configured registry.
|
|
|
|
|
- ``ria-app run <app>[:tag]`` — pull (if needed) and run, auto-configuring
|
|
|
|
|
GPU/USB/network flags from image labels set by CI.
|
|
|
|
|
- ``ria-app list`` — list locally cached RIA app images.
|
|
|
|
|
- ``ria-app stop <app>`` — stop a running app container.
|
|
|
|
|
- ``ria-app logs <app>`` — tail logs of a running app container.
|
|
|
|
|
- ``ria-app configure`` — set default registry/namespace.
|
|
|
|
|
|
|
|
|
|
Image references resolve as::
|
|
|
|
|
|
|
|
|
|
my-classifier -> {registry}/{namespace}/my-classifier:latest
|
|
|
|
|
group/my-classifier -> {registry}/group/my-classifier:latest
|
|
|
|
|
host/group/app:tag -> host/group/app:tag (fully-qualified passthrough)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
J
2026-04-16 11:13:43 -04:00
|
|
|
import os
|
J
2026-04-14 13:03:26 -04:00
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
from . import config as _config
|
|
|
|
|
|
|
|
|
|
_LABEL_PROFILE = "ria.profile"
|
|
|
|
|
_LABEL_HARDWARE = "ria.hardware"
|
|
|
|
|
_LABEL_APP = "ria.app"
|
|
|
|
|
|
|
|
|
|
|
J
2026-04-14 13:18:34 -04:00
|
|
|
def _engine(cfg: _config.AppConfig, sudo_override: bool = False) -> list[str]:
|
J
2026-04-14 13:03:26 -04:00
|
|
|
for exe in ("docker", "podman"):
|
|
|
|
|
if shutil.which(exe):
|
J
2026-04-14 13:18:34 -04:00
|
|
|
use_sudo = sudo_override or cfg.sudo
|
|
|
|
|
return (["sudo", exe] if use_sudo else [exe])
|
J
2026-04-14 13:03:26 -04:00
|
|
|
print("error: neither 'docker' nor 'podman' found on PATH", file=sys.stderr)
|
|
|
|
|
sys.exit(2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_ref(app: str, cfg: _config.AppConfig) -> str:
|
|
|
|
|
ref = app if ":" in app.split("/")[-1] else f"{app}:latest"
|
|
|
|
|
slashes = ref.count("/")
|
|
|
|
|
if slashes >= 2:
|
|
|
|
|
return ref
|
|
|
|
|
if slashes == 1:
|
|
|
|
|
return f"{cfg.registry}/{ref}" if cfg.registry else ref
|
|
|
|
|
if not cfg.registry or not cfg.namespace:
|
|
|
|
|
print(
|
|
|
|
|
"error: app is not fully qualified and no default registry/namespace configured. "
|
|
|
|
|
"Run `ria-app configure` or pass a full image reference (registry/namespace/app:tag).",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
sys.exit(2)
|
|
|
|
|
return f"{cfg.registry}/{cfg.namespace}/{ref}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _container_name(ref: str) -> str:
|
|
|
|
|
name = ref.rsplit("/", 1)[-1].split(":", 1)[0]
|
|
|
|
|
return f"ria-app-{name}"
|
|
|
|
|
|
|
|
|
|
|
J
2026-04-14 13:18:34 -04:00
|
|
|
def _inspect_labels(engine: list[str], ref: str) -> dict:
|
J
2026-04-14 13:03:26 -04:00
|
|
|
try:
|
|
|
|
|
out = subprocess.check_output(
|
J
2026-04-14 13:18:34 -04:00
|
|
|
[*engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref],
|
J
2026-04-14 13:03:26 -04:00
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
)
|
|
|
|
|
except subprocess.CalledProcessError:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(out.decode().strip()) or {}
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
J
2026-04-16 11:13:43 -04:00
|
|
|
def _gpu_available() -> bool:
|
|
|
|
|
if os.path.exists("/dev/nvidia0"):
|
|
|
|
|
return True
|
|
|
|
|
return shutil.which("nvidia-smi") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hardware_flags(labels: dict, no_gpu: bool, no_usb: bool, no_host_net: bool) -> tuple[list[str], list[str]]:
|
J
2026-04-14 13:03:26 -04:00
|
|
|
flags: list[str] = []
|
J
2026-04-16 11:13:43 -04:00
|
|
|
notes: list[str] = []
|
J
2026-04-14 13:03:26 -04:00
|
|
|
profile = (labels.get(_LABEL_PROFILE) or "").lower()
|
|
|
|
|
hardware = (labels.get(_LABEL_HARDWARE) or "").lower()
|
|
|
|
|
hw_items = {h.strip() for h in hardware.split(",") if h.strip()}
|
|
|
|
|
|
J
2026-04-16 11:13:43 -04:00
|
|
|
wants_gpu = any(k in profile for k in ("nvidia", "holoscan", "cuda"))
|
|
|
|
|
if wants_gpu and not no_gpu:
|
|
|
|
|
if _gpu_available():
|
|
|
|
|
flags += ["--gpus", "all"]
|
|
|
|
|
else:
|
|
|
|
|
notes.append("image wants GPU but no NVIDIA runtime detected — skipping --gpus (use --force-gpu to override)")
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
J
2026-04-16 11:13:43 -04:00
|
|
|
if hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} and not no_usb:
|
J
2026-04-14 13:03:26 -04:00
|
|
|
flags += ["--device", "/dev/bus/usb"]
|
|
|
|
|
|
J
2026-04-16 11:13:43 -04:00
|
|
|
if hw_items & {"usrp", "thinkrf", "pluto"} and not no_host_net:
|
J
2026-04-14 13:03:26 -04:00
|
|
|
flags += ["--net", "host"]
|
|
|
|
|
|
J
2026-04-16 11:13:43 -04:00
|
|
|
return flags, notes
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmd_configure(args: argparse.Namespace) -> int:
|
|
|
|
|
cfg = _config.load()
|
|
|
|
|
if args.registry:
|
|
|
|
|
cfg.registry = args.registry
|
|
|
|
|
if args.namespace:
|
|
|
|
|
cfg.namespace = args.namespace
|
J
2026-04-14 13:18:34 -04:00
|
|
|
if args.sudo is not None:
|
|
|
|
|
cfg.sudo = args.sudo
|
J
2026-04-14 13:03:26 -04:00
|
|
|
path = _config.save(cfg)
|
|
|
|
|
print(f"Saved app config to {path}")
|
|
|
|
|
print(f" registry: {cfg.registry or '(unset)'}")
|
|
|
|
|
print(f" namespace: {cfg.namespace or '(unset)'}")
|
J
2026-04-14 13:18:34 -04:00
|
|
|
print(f" sudo: {cfg.sudo}")
|
J
2026-04-14 13:03:26 -04:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmd_pull(args: argparse.Namespace) -> int:
|
|
|
|
|
cfg = _config.load()
|
J
2026-04-14 13:18:34 -04:00
|
|
|
engine = _engine(cfg, args.sudo)
|
J
2026-04-14 13:03:26 -04:00
|
|
|
ref = _resolve_ref(args.app, cfg)
|
|
|
|
|
print(f"Pulling {ref}")
|
J
2026-04-14 13:18:34 -04:00
|
|
|
return subprocess.call([*engine, "pull", ref])
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmd_run(args: argparse.Namespace) -> int:
|
|
|
|
|
cfg = _config.load()
|
J
2026-04-14 13:18:34 -04:00
|
|
|
engine = _engine(cfg, args.sudo)
|
J
2026-04-14 13:03:26 -04:00
|
|
|
ref = _resolve_ref(args.app, cfg)
|
|
|
|
|
|
|
|
|
|
if not _inspect_labels(engine, ref):
|
J
2026-04-14 13:18:34 -04:00
|
|
|
rc = subprocess.call([*engine, "pull", ref])
|
J
2026-04-14 13:03:26 -04:00
|
|
|
if rc != 0:
|
|
|
|
|
return rc
|
|
|
|
|
|
|
|
|
|
labels = _inspect_labels(engine, ref)
|
J
2026-04-16 11:13:43 -04:00
|
|
|
no_gpu = args.no_gpu and not args.force_gpu
|
|
|
|
|
hw_flags, notes = _hardware_flags(labels, no_gpu=no_gpu, no_usb=args.no_usb, no_host_net=args.no_host_net)
|
|
|
|
|
if args.force_gpu and "--gpus" not in hw_flags:
|
|
|
|
|
hw_flags = ["--gpus", "all", *hw_flags]
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
J
2026-04-14 13:18:34 -04:00
|
|
|
cmd = [*engine, "run", "--rm"]
|
J
2026-04-14 13:03:26 -04:00
|
|
|
if not args.foreground:
|
|
|
|
|
cmd += ["-d"]
|
|
|
|
|
cmd += ["--name", args.name or _container_name(ref)]
|
|
|
|
|
cmd += hw_flags
|
|
|
|
|
|
|
|
|
|
if args.config:
|
|
|
|
|
cmd += ["-v", f"{args.config}:/config/config.yaml:ro", "-e", "RIA_CONFIG=/config/config.yaml"]
|
|
|
|
|
|
|
|
|
|
for env in args.env or []:
|
|
|
|
|
cmd += ["-e", env]
|
|
|
|
|
for vol in args.volume or []:
|
|
|
|
|
cmd += ["-v", vol]
|
|
|
|
|
for port in args.publish or []:
|
|
|
|
|
cmd += ["-p", port]
|
|
|
|
|
|
|
|
|
|
cmd += list(args.docker_args or [])
|
|
|
|
|
cmd += [ref]
|
|
|
|
|
cmd += list(args.app_args or [])
|
|
|
|
|
|
|
|
|
|
if args.dry_run:
|
|
|
|
|
print(" ".join(cmd))
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
label_str = ", ".join(f"{k}={v}" for k, v in labels.items() if k.startswith("ria.")) or "(no ria.* labels)"
|
|
|
|
|
print(f"Running {ref} [{label_str}]")
|
|
|
|
|
if hw_flags:
|
|
|
|
|
print(f" auto flags: {' '.join(hw_flags)}")
|
J
2026-04-16 11:13:43 -04:00
|
|
|
for note in notes:
|
|
|
|
|
print(f" note: {note}")
|
J
2026-04-14 13:03:26 -04:00
|
|
|
return subprocess.call(cmd)
|
|
|
|
|
|
|
|
|
|
|
J
2026-04-14 13:18:34 -04:00
|
|
|
def _cmd_list(args: argparse.Namespace) -> int:
|
|
|
|
|
cfg = _config.load()
|
|
|
|
|
engine = _engine(cfg, args.sudo)
|
J
2026-04-14 13:03:26 -04:00
|
|
|
return subprocess.call(
|
|
|
|
|
[
|
J
2026-04-14 13:18:34 -04:00
|
|
|
*engine,
|
J
2026-04-14 13:03:26 -04:00
|
|
|
"images",
|
|
|
|
|
"--filter",
|
|
|
|
|
f"label={_LABEL_APP}",
|
|
|
|
|
"--format",
|
|
|
|
|
"table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmd_stop(args: argparse.Namespace) -> int:
|
J
2026-04-14 13:18:34 -04:00
|
|
|
cfg = _config.load()
|
|
|
|
|
engine = _engine(cfg, args.sudo)
|
|
|
|
|
name = args.name or _container_name(_resolve_ref(args.app, cfg))
|
|
|
|
|
return subprocess.call([*engine, "stop", name])
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmd_logs(args: argparse.Namespace) -> int:
|
J
2026-04-14 13:18:34 -04:00
|
|
|
cfg = _config.load()
|
|
|
|
|
engine = _engine(cfg, args.sudo)
|
|
|
|
|
name = args.name or _container_name(_resolve_ref(args.app, cfg))
|
|
|
|
|
cmd = [*engine, "logs"]
|
J
2026-04-14 13:03:26 -04:00
|
|
|
if args.follow:
|
|
|
|
|
cmd += ["-f"]
|
|
|
|
|
cmd += [name]
|
|
|
|
|
return subprocess.call(cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
parser = argparse.ArgumentParser(prog="ria-app")
|
J
2026-04-14 13:18:34 -04:00
|
|
|
parser.add_argument("--sudo", action="store_true", default=False, help="Run docker/podman via sudo")
|
J
2026-04-14 13:03:26 -04:00
|
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
p_cfg = sub.add_parser("configure", help="Set default registry/namespace")
|
|
|
|
|
p_cfg.add_argument("--registry", default=None, help="Default container registry (e.g. registry.riahub.ai)")
|
|
|
|
|
p_cfg.add_argument("--namespace", default=None, help="Default namespace (e.g. qoherent)")
|
J
2026-04-14 13:18:34 -04:00
|
|
|
p_cfg.add_argument(
|
|
|
|
|
"--sudo",
|
|
|
|
|
dest="sudo",
|
|
|
|
|
action=argparse.BooleanOptionalAction,
|
|
|
|
|
default=None,
|
|
|
|
|
help="Persist sudo default (--sudo / --no-sudo)",
|
|
|
|
|
)
|
J
2026-04-14 13:03:26 -04:00
|
|
|
|
|
|
|
|
p_pull = sub.add_parser("pull", help="Pull an app image")
|
|
|
|
|
p_pull.add_argument("app", help="App name or image reference")
|
|
|
|
|
|
|
|
|
|
p_run = sub.add_parser("run", help="Run an app, auto-detecting hardware flags")
|
|
|
|
|
p_run.add_argument("app", help="App name or image reference")
|
|
|
|
|
p_run.add_argument("--name", default=None, help="Container name (default: ria-app-<app>)")
|
|
|
|
|
p_run.add_argument("--config", default=None, help="Path to config.yaml to mount into the container")
|
|
|
|
|
p_run.add_argument("-e", "--env", action="append", help="Extra env var (KEY=VALUE)")
|
|
|
|
|
p_run.add_argument("-v", "--volume", action="append", help="Extra volume mount")
|
|
|
|
|
p_run.add_argument("-p", "--publish", action="append", help="Publish port")
|
|
|
|
|
p_run.add_argument("--foreground", "-F", action="store_true", help="Run in foreground (no -d)")
|
J
2026-04-16 11:13:43 -04:00
|
|
|
p_run.add_argument("--no-gpu", action="store_true", help="Skip --gpus flag even if image wants GPU")
|
|
|
|
|
p_run.add_argument("--force-gpu", action="store_true", help="Force --gpus all even if no NVIDIA runtime detected")
|
|
|
|
|
p_run.add_argument("--no-usb", action="store_true", help="Skip --device /dev/bus/usb")
|
|
|
|
|
p_run.add_argument("--no-host-net", action="store_true", help="Skip --net host")
|
J
2026-04-14 13:03:26 -04:00
|
|
|
p_run.add_argument("--dry-run", action="store_true", help="Print the container command and exit")
|
|
|
|
|
p_run.add_argument("--docker-args", nargs=argparse.REMAINDER, help="Pass remaining args to docker/podman run")
|
|
|
|
|
p_run.add_argument("--app-args", nargs=argparse.REMAINDER, help="Pass remaining args to the app entrypoint")
|
|
|
|
|
|
|
|
|
|
sub.add_parser("list", help="List locally cached RIA app images")
|
|
|
|
|
|
|
|
|
|
p_stop = sub.add_parser("stop", help="Stop a running app")
|
|
|
|
|
p_stop.add_argument("app", help="App name or image reference")
|
|
|
|
|
p_stop.add_argument("--name", default=None, help="Container name override")
|
|
|
|
|
|
|
|
|
|
p_logs = sub.add_parser("logs", help="Tail logs of a running app")
|
|
|
|
|
p_logs.add_argument("app", help="App name or image reference")
|
|
|
|
|
p_logs.add_argument("--name", default=None, help="Container name override")
|
|
|
|
|
p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log output")
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
dispatch = {
|
|
|
|
|
"configure": _cmd_configure,
|
|
|
|
|
"pull": _cmd_pull,
|
|
|
|
|
"run": _cmd_run,
|
|
|
|
|
"list": _cmd_list,
|
|
|
|
|
"stop": _cmd_stop,
|
|
|
|
|
"logs": _cmd_logs,
|
|
|
|
|
}
|
|
|
|
|
sys.exit(dispatch[args.command](args))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|