249 lines
8.9 KiB
Python
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}"
|
|
)
|