feat: reachability and consumer profiles (SAND-WP-0011)
Add reachability enrichment (tunnel metadata, ops-bridge pointer), secret_refs boundary resolution, profile.agent-dev and profile.build, CLI reachability show, API endpoint, consumer smoke scripts, and tests.
This commit is contained in:
parent
7cabf77fb6
commit
1f87be4c6b
20 changed files with 522 additions and 34 deletions
|
|
@ -34,6 +34,14 @@ def get_sandbox(sandbox_id: str) -> SandboxStatus:
|
|||
return status
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes/{sandbox_id}/reachability")
|
||||
def get_sandbox_reachability(sandbox_id: str) -> dict:
|
||||
try:
|
||||
return _manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
|||
app.add_typer(credits_app, name="credits")
|
||||
snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.")
|
||||
app.add_typer(snapshots_app, name="snapshots")
|
||||
reachability_app = typer.Typer(help="Consumer reachability descriptors.")
|
||||
app.add_typer(reachability_app, name="reachability")
|
||||
|
||||
|
||||
@app.callback()
|
||||
|
|
@ -112,6 +114,18 @@ def sandbox_create(
|
|||
_print_telemetry_summary(status.telemetry)
|
||||
|
||||
|
||||
@reachability_app.command("show")
|
||||
def reachability_show(sandbox_id: str) -> None:
|
||||
"""Show reachability descriptor and SSH one-liner for a sandbox."""
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
report = manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_json(report)
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ from sandboxer.payments.credits import CreditsStore
|
|||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.reachability.enrich import enrich_reachability
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
from sandboxer.snapshots.store import SnapshotStore
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
|
|
@ -127,7 +129,11 @@ class SandboxManager:
|
|||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
handle = backend.provision(profile, request.inputs, resolved_host)
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
provision_inputs = dict(request.inputs)
|
||||
handle = backend.provision(profile, provision_inputs, resolved_host)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
status.sandbox_id = handle["sandbox_id"]
|
||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||
|
|
@ -139,6 +145,7 @@ class SandboxManager:
|
|||
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
||||
status.inputs["provider"] = handle.get("provider", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
|
|
@ -178,6 +185,14 @@ class SandboxManager:
|
|||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
return self.store.get(sandbox_id)
|
||||
|
||||
def reachability_report(self, sandbox_id: str) -> dict:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
from sandboxer.reachability.enrich import build_reachability_report
|
||||
|
||||
return build_reachability_report(status)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
|
|
@ -453,7 +468,11 @@ class SandboxManager:
|
|||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
status.inputs["restored_from"] = record.snapshot_id
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ class Reachability(BaseModel):
|
|||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
endpoint: str | None = None
|
||||
tunnel: str | None = None
|
||||
tunnel_via: str | None = None
|
||||
identity: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
|
|
|
|||
5
src/sandboxer/reachability/__init__.py
Normal file
5
src/sandboxer/reachability/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Reachability descriptor enrichment."""
|
||||
|
||||
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
|
||||
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
68
src/sandboxer/reachability/enrich.py
Normal file
68
src/sandboxer/reachability/enrich.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Merge profile reachability spec and env into consumer descriptors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.models import Profile, Reachability, SandboxStatus
|
||||
|
||||
OPS_BRIDGE_DOC = "ops-bridge MCP or `bridge` CLI — sand-boxer does not manage tunnels"
|
||||
|
||||
|
||||
def enrich_reachability(
|
||||
reach: dict[str, str],
|
||||
profile: Profile,
|
||||
handle: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Add tunnel/identity metadata from profile spec and environment."""
|
||||
enriched = dict(reach)
|
||||
spec = profile.reachability
|
||||
|
||||
if spec.tunnel:
|
||||
enriched.setdefault("tunnel_via", spec.tunnel)
|
||||
if spec.identity:
|
||||
enriched["identity"] = spec.identity
|
||||
|
||||
tunnel_port = (
|
||||
os.environ.get("SANDBOXER_TUNNEL_PORT")
|
||||
or handle.get("tunnel_port")
|
||||
or handle.get("ssh_port")
|
||||
)
|
||||
tunnel_alias = os.environ.get("SANDBOXER_TUNNEL_ALIAS") or handle.get("vm_target")
|
||||
if tunnel_port:
|
||||
enriched["tunnel"] = f"localhost:{tunnel_port}"
|
||||
elif tunnel_alias:
|
||||
enriched["tunnel"] = tunnel_alias
|
||||
|
||||
tunnel_via = os.environ.get("SANDBOXER_TUNNEL_VIA")
|
||||
if tunnel_via:
|
||||
enriched["tunnel_via"] = tunnel_via
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def ssh_one_liner(reach: Reachability) -> str | None:
|
||||
if reach.ssh and reach.remote_dir:
|
||||
return f"ssh {reach.ssh} 'cd {reach.remote_dir} && exec $SHELL'"
|
||||
if reach.ssh:
|
||||
return f"ssh {reach.ssh}"
|
||||
return None
|
||||
|
||||
|
||||
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
||||
"""Consumer-facing reachability report with ops-bridge pointer."""
|
||||
reach = status.reachability
|
||||
payload: dict[str, Any] = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"host": status.host,
|
||||
"reachability": reach.model_dump(mode="json") if reach else None,
|
||||
"ops_bridge": {
|
||||
"doc": OPS_BRIDGE_DOC,
|
||||
"note": "Bring tunnels up via ops-bridge; sand-boxer emits descriptor only",
|
||||
},
|
||||
}
|
||||
if reach:
|
||||
payload["ssh_one_liner"] = ssh_one_liner(reach)
|
||||
return payload
|
||||
5
src/sandboxer/secrets/__init__.py
Normal file
5
src/sandboxer/secrets/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Setup secret resolution at provision boundary."""
|
||||
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
|
||||
__all__ = ["resolve_setup_secrets"]
|
||||
41
src/sandboxer/secrets/resolver.py
Normal file
41
src/sandboxer/secrets/resolver.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Resolve profile.setup.secret_refs from operator-injected env (BYOK boundary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _secret_env_name(ref: str) -> str:
|
||||
normalized = ref.upper().replace("-", "_").replace(".", "_")
|
||||
return f"SANDBOXER_SECRET_{normalized}"
|
||||
|
||||
|
||||
def resolve_secret_ref(ref: str) -> str | None:
|
||||
"""Resolve a single secret ref from env. OpenBao injection is operator-owned."""
|
||||
return os.environ.get(_secret_env_name(ref))
|
||||
|
||||
|
||||
def resolve_setup_secrets(profile: Profile) -> dict[str, str]:
|
||||
"""Resolve all profile secret_refs or raise if any are missing."""
|
||||
refs = profile.setup.secret_refs
|
||||
if not refs:
|
||||
return {}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for ref in refs:
|
||||
value = resolve_secret_ref(ref)
|
||||
if value:
|
||||
resolved[ref] = value
|
||||
else:
|
||||
missing.append(ref)
|
||||
|
||||
if missing:
|
||||
env_hints = ", ".join(_secret_env_name(r) for r in missing)
|
||||
raise ValueError(
|
||||
f"Unresolved secret_refs for {profile.id}: {missing}. "
|
||||
f"Set env ({env_hints}) or use warden route find for OpenBao path."
|
||||
)
|
||||
return resolved
|
||||
Loading…
Add table
Add a link
Reference in a new issue