From 87bc78e063ef04f2b32c560d2db683bcb0b5eeb4 Mon Sep 17 00:00:00 2001 From: jonny Date: Tue, 14 Apr 2026 13:03:26 -0400 Subject: [PATCH] new commands --- pyproject.toml | 1 + src/ria_toolkit_oss/app/__init__.py | 1 + src/ria_toolkit_oss/app/cli.py | 242 ++++++++++++++++++++++++++++ src/ria_toolkit_oss/app/config.py | 49 ++++++ 4 files changed, 293 insertions(+) create mode 100644 src/ria_toolkit_oss/app/__init__.py create mode 100644 src/ria_toolkit_oss/app/cli.py create mode 100644 src/ria_toolkit_oss/app/config.py diff --git a/pyproject.toml b/pyproject.toml index a0bd664..3dc1fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ ria = "ria_toolkit_oss_cli.cli:cli" ria-tools = "ria_toolkit_oss_cli.cli:cli" ria-server = "ria_toolkit_oss.server.cli:serve" ria-agent = "ria_toolkit_oss.agent.cli:main" +ria-app = "ria_toolkit_oss.app.cli:main" [tool.poetry.group.server.dependencies] fastapi = ">=0.111,<1.0" diff --git a/src/ria_toolkit_oss/app/__init__.py b/src/ria_toolkit_oss/app/__init__.py new file mode 100644 index 0000000..465659f --- /dev/null +++ b/src/ria_toolkit_oss/app/__init__.py @@ -0,0 +1 @@ +"""App runner: pull and run containerized RIA applications.""" diff --git a/src/ria_toolkit_oss/app/cli.py b/src/ria_toolkit_oss/app/cli.py new file mode 100644 index 0000000..c70eb16 --- /dev/null +++ b/src/ria_toolkit_oss/app/cli.py @@ -0,0 +1,242 @@ +"""Unified ``ria-app`` CLI. + +Subcommands: + +- ``ria-app pull [:tag]`` — pull a RIA app image from the configured registry. +- ``ria-app run [: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 `` — stop a running app container. +- ``ria-app logs `` — 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 +import shutil +import subprocess +import sys + +from . import config as _config + +_LABEL_PROFILE = "ria.profile" +_LABEL_HARDWARE = "ria.hardware" +_LABEL_APP = "ria.app" + + +def _engine() -> str: + for exe in ("docker", "podman"): + if shutil.which(exe): + return exe + 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}" + + +def _inspect_labels(engine: str, ref: str) -> dict: + try: + out = subprocess.check_output( + [engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref], + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return {} + try: + return json.loads(out.decode().strip()) or {} + except json.JSONDecodeError: + return {} + + +def _hardware_flags(labels: dict) -> list[str]: + flags: list[str] = [] + 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()} + + if "nvidia" in profile or "holoscan" in profile or "cuda" in profile: + flags += ["--gpus", "all"] + + needs_usb = hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} + if needs_usb: + flags += ["--device", "/dev/bus/usb"] + + needs_net = hw_items & {"usrp", "thinkrf", "pluto"} + if needs_net: + flags += ["--net", "host"] + + return flags + + +def _cmd_configure(args: argparse.Namespace) -> int: + cfg = _config.load() + if args.registry: + cfg.registry = args.registry + if args.namespace: + cfg.namespace = args.namespace + 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)'}") + return 0 + + +def _cmd_pull(args: argparse.Namespace) -> int: + engine = _engine() + cfg = _config.load() + ref = _resolve_ref(args.app, cfg) + print(f"Pulling {ref}") + return subprocess.call([engine, "pull", ref]) + + +def _cmd_run(args: argparse.Namespace) -> int: + engine = _engine() + cfg = _config.load() + ref = _resolve_ref(args.app, cfg) + + if not _inspect_labels(engine, ref): + rc = subprocess.call([engine, "pull", ref]) + if rc != 0: + return rc + + labels = _inspect_labels(engine, ref) + hw_flags = _hardware_flags(labels) + + cmd = [engine, "run", "--rm"] + 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)}") + return subprocess.call(cmd) + + +def _cmd_list(_args: argparse.Namespace) -> int: + engine = _engine() + return subprocess.call( + [ + engine, + "images", + "--filter", + f"label={_LABEL_APP}", + "--format", + "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}", + ] + ) + + +def _cmd_stop(args: argparse.Namespace) -> int: + engine = _engine() + name = args.name or _container_name(_resolve_ref(args.app, _config.load())) + return subprocess.call([engine, "stop", name]) + + +def _cmd_logs(args: argparse.Namespace) -> int: + engine = _engine() + name = args.name or _container_name(_resolve_ref(args.app, _config.load())) + cmd = [engine, "logs"] + if args.follow: + cmd += ["-f"] + cmd += [name] + return subprocess.call(cmd) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="ria-app") + 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)") + + 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-)") + 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)") + 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() diff --git a/src/ria_toolkit_oss/app/config.py b/src/ria_toolkit_oss/app/config.py new file mode 100644 index 0000000..2594761 --- /dev/null +++ b/src/ria_toolkit_oss/app/config.py @@ -0,0 +1,49 @@ +"""App runner configuration at ``~/.ria/toolkit.json``. + +Schema:: + + { + "registry": "registry.riahub.ai", + "namespace": "qoherent" + } +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path + +_DEFAULT_PATH = Path(os.environ.get("RIA_TOOLKIT_CONFIG", str(Path.home() / ".ria" / "toolkit.json"))) + + +@dataclass +class AppConfig: + registry: str = "" + namespace: str = "" + + +def default_path() -> Path: + return _DEFAULT_PATH + + +def load(path: Path | None = None) -> AppConfig: + p = path or _DEFAULT_PATH + if not p.exists(): + return AppConfig( + registry=os.environ.get("RIA_REGISTRY", ""), + namespace=os.environ.get("RIA_NAMESPACE", ""), + ) + data = json.loads(p.read_text()) + return AppConfig( + registry=data.get("registry", "") or os.environ.get("RIA_REGISTRY", ""), + namespace=data.get("namespace", "") or os.environ.get("RIA_NAMESPACE", ""), + ) + + +def save(cfg: AppConfig, path: Path | None = None) -> Path: + p = path or _DEFAULT_PATH + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(asdict(cfg), indent=2)) + return p