Files
NODEDC_MISSION_CORE/src/k1link/artifact_cli.py
T

249 lines
8.9 KiB
Python

"""Operator commands for the optional Mission Core artifact gateway."""
from __future__ import annotations
import json
import mimetypes
import os
from pathlib import Path
from typing import Annotated
import typer
from rich.console import Console
from k1link.artifact_gateway import (
CACHE_MAX_BYTES_ENV,
CACHE_RESERVE_BYTES_ENV,
DEFAULT_CACHE_MAX_BYTES,
DEFAULT_FREE_SPACE_RESERVE_BYTES,
STORE_ROOT_ENV,
ArtifactGateway,
ArtifactGatewayError,
CentralArtifactStore,
LocalArtifactCache,
)
from k1link.sessions.store import resolve_missioncore_data_dir
app = typer.Typer(
help="Publish, pin, inspect, and resolve verified central Mission Core artifacts.",
no_args_is_help=True,
)
console = Console()
def _positive_environment_bytes(name: str, default: int) -> int:
value = os.environ.get(name, "").strip()
if not value:
return default
if not value.isascii() or not value.isdecimal() or int(value) <= 0:
raise ValueError(f"{name} must be a positive integer")
return int(value)
def _non_negative_environment_bytes(name: str, default: int) -> int:
value = os.environ.get(name, "").strip()
if not value:
return default
if not value.isascii() or not value.isdecimal():
raise ValueError(f"{name} must be a non-negative integer")
return int(value)
def _gateway(repository_root: Path, *, create_store: bool = False) -> ArtifactGateway:
configured = os.environ.get(STORE_ROOT_ENV, "").strip()
if not configured:
raise ValueError(f"{STORE_ROOT_ENV} is not configured")
data_dir = resolve_missioncore_data_dir(repository_root)
return ArtifactGateway(
CentralArtifactStore(Path(configured), create=create_store),
LocalArtifactCache(
data_dir / "artifact-cache",
max_bytes=_positive_environment_bytes(
CACHE_MAX_BYTES_ENV,
DEFAULT_CACHE_MAX_BYTES,
),
free_space_reserve_bytes=_non_negative_environment_bytes(
CACHE_RESERVE_BYTES_ENV,
DEFAULT_FREE_SPACE_RESERVE_BYTES,
),
),
)
def _parse_assignment(value: str, *, label: str) -> tuple[str, str]:
key, separator, item = value.partition("=")
if not separator or not key or not item:
raise ValueError(f"{label} must use KEY=VALUE")
return key, item
def _media_type(path: Path) -> str:
explicit = {
".rrd": "application/vnd.rerun.rrd",
".jsonl": "application/x-ndjson",
".npz": "application/x-numpy",
".k1mqtt": "application/vnd.nodedc.k1mqtt",
".tar": "application/x-tar",
".tgz": "application/gzip",
".m4s": "video/iso.segment",
}.get(path.suffix.lower())
if explicit is not None:
return explicit
guessed, _encoding = mimetypes.guess_type(path.name)
return guessed or "application/octet-stream"
@app.command("publish")
def publish(
namespace: Annotated[str, typer.Option("--namespace")],
key: Annotated[str, typer.Option("--key")],
artifact_type: Annotated[str, typer.Option("--artifact-type")],
subject_id: Annotated[str, typer.Option("--subject-id")],
member: Annotated[
list[str],
typer.Option(
"--member",
help="Repeat ROLE=/absolute/file/path for every immutable bundle member.",
),
],
metadata: Annotated[
list[str] | None,
typer.Option("--metadata", help="Optional repeatable KEY=VALUE metadata."),
] = None,
) -> None:
"""Publish exact files, then atomically advance one named central reference."""
repository_root = Path(__file__).resolve().parents[2]
try:
sources = []
for raw in member:
role, raw_path = _parse_assignment(raw, label="member")
path = Path(raw_path).expanduser().resolve(strict=True)
sources.append((role, _media_type(path), path))
metadata_values = dict(
_parse_assignment(raw, label="metadata") for raw in (metadata or [])
)
manifest = _gateway(repository_root, create_store=True).publish(
namespace=namespace,
key=key,
artifact_type=artifact_type,
subject_id=subject_id,
sources=sources,
metadata=metadata_values,
)
except (ArtifactGatewayError, OSError, ValueError) as exc:
console.print(f"[red]Artifact publication failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
total_bytes = sum(item.byte_length for item in manifest.members)
console.print(
"[green]Central artifact published.[/green] "
f"reference={namespace}/{key}; manifest={manifest.manifest_id}; "
f"objects={len(manifest.members)}; logical_bytes={total_bytes}"
)
@app.command("pin")
def pin(
namespace: Annotated[str, typer.Option("--namespace")],
key: Annotated[str, typer.Option("--key")],
pin_id: Annotated[str | None, typer.Option("--pin-id")] = None,
) -> None:
"""Fetch and persistently pin every object in one named bundle."""
repository_root = Path(__file__).resolve().parents[2]
selected_pin = pin_id or f"{namespace}:{key}"
try:
gateway = _gateway(repository_root)
manifest = gateway.pin_reference(namespace, key, pin_id=selected_pin)
status = gateway.cache.status()
except (ArtifactGatewayError, OSError, ValueError) as exc:
console.print(f"[red]Artifact pin failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
console.print(
"[green]Artifact bundle pinned.[/green] "
f"manifest={manifest.manifest_id}; pin={selected_pin}; "
f"pinned_objects={status.pinned_object_count}; pinned_bytes={status.pinned_bytes}"
)
@app.command("unpin")
def unpin(pin_id: Annotated[str, typer.Option("--pin-id")]) -> None:
"""Remove one persistent pin and immediately enforce local LRU limits."""
repository_root = Path(__file__).resolve().parents[2]
try:
gateway = _gateway(repository_root)
gateway.cache.unpin(pin_id)
status = gateway.cache.status()
except (ArtifactGatewayError, OSError, ValueError) as exc:
console.print(f"[red]Artifact unpin failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
console.print(
"[green]Artifact pin removed.[/green] "
f"pin={pin_id}; cached_bytes={status.total_bytes}; "
f"pinned_bytes={status.pinned_bytes}"
)
@app.command("resolve")
def resolve(
namespace: Annotated[str, typer.Option("--namespace")],
key: Annotated[str, typer.Option("--key")],
role: Annotated[str, typer.Option("--role")],
json_output: Annotated[bool, typer.Option("--json")] = False,
) -> None:
"""Resolve one role to a verified local path, fetching it when online."""
repository_root = Path(__file__).resolve().parents[2]
try:
artifact = _gateway(repository_root).resolve_role(namespace, key, role)
except (ArtifactGatewayError, OSError, ValueError) as exc:
console.print(f"[red]Artifact resolution failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
document = {
"manifest_id": artifact.manifest.manifest_id,
"role": artifact.member.role,
"sha256": artifact.member.sha256,
"byte_length": artifact.member.byte_length,
"path": str(artifact.path),
"cache_hit": artifact.cache_hit,
"central_available": artifact.central_available,
}
if json_output:
typer.echo(json.dumps(document, ensure_ascii=False, indent=2))
return
console.print(
"[green]Artifact resolved.[/green] "
f"role={role}; path={artifact.path}; cache_hit={artifact.cache_hit}; "
f"central_available={artifact.central_available}"
)
@app.command("status")
def status(json_output: Annotated[bool, typer.Option("--json")] = False) -> None:
"""Report local working-set quota and persistent pin usage."""
repository_root = Path(__file__).resolve().parents[2]
try:
cache_status = _gateway(repository_root).cache.status()
except (ArtifactGatewayError, OSError, ValueError) as exc:
console.print(f"[red]Artifact status failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
document = {
"object_count": cache_status.object_count,
"total_bytes": cache_status.total_bytes,
"pinned_object_count": cache_status.pinned_object_count,
"pinned_bytes": cache_status.pinned_bytes,
"cache_max_bytes": cache_status.cache_max_bytes,
"free_space_reserve_bytes": cache_status.free_space_reserve_bytes,
}
if json_output:
typer.echo(json.dumps(document, ensure_ascii=False, indent=2))
return
console.print(
f"objects={cache_status.object_count}; bytes={cache_status.total_bytes}; "
f"pinned_objects={cache_status.pinned_object_count}; "
f"pinned_bytes={cache_status.pinned_bytes}; "
f"quota={cache_status.cache_max_bytes}"
)