Add workspace network policy and published ports
Replace the workspace-level boolean network toggle with explicit network policies and attach localhost TCP publication to workspace services. Persist network_policy in workspace records, validate --publish requests, and run host-side proxy helpers that follow the service lifecycle so published ports are cleaned up on failure, stop, reset, and delete. Update the CLI, SDK, MCP contract, docs, roadmap, and examples for the new policy model, add coverage for the proxy and manager edge cases, and validate with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed published-port probe smoke.
This commit is contained in:
parent
fc72fcd3a1
commit
c82f4629b2
21 changed files with 1944 additions and 49 deletions
|
|
@ -160,6 +160,7 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
print(f"Environment: {str(payload.get('environment', 'unknown'))}")
|
||||
print(f"State: {str(payload.get('state', 'unknown'))}")
|
||||
print(f"Workspace: {str(payload.get('workspace_path', '/workspace'))}")
|
||||
print(f"Network policy: {str(payload.get('network_policy', 'off'))}")
|
||||
workspace_seed = payload.get("workspace_seed")
|
||||
if isinstance(workspace_seed, dict):
|
||||
mode = str(workspace_seed.get("mode", "empty"))
|
||||
|
|
@ -378,13 +379,27 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
|
||||
published_ports = payload.get("published_ports")
|
||||
published_text = ""
|
||||
if isinstance(published_ports, list) and published_ports:
|
||||
parts = []
|
||||
for item in published_ports:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
parts.append(
|
||||
f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}"
|
||||
f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}"
|
||||
)
|
||||
if parts:
|
||||
published_text = " published_ports=" + ",".join(parts)
|
||||
print(
|
||||
f"[{prefix}] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"service_name={str(payload.get('service_name', 'unknown'))} "
|
||||
f"state={str(payload.get('state', 'unknown'))} "
|
||||
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
|
||||
f"{published_text}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
|
@ -402,6 +417,18 @@ def _print_workspace_service_list_human(payload: dict[str, Any]) -> None:
|
|||
f"{str(service.get('service_name', 'unknown'))} "
|
||||
f"[{str(service.get('state', 'unknown'))}] "
|
||||
f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}"
|
||||
+ (
|
||||
" published="
|
||||
+ ",".join(
|
||||
f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}"
|
||||
f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}"
|
||||
for item in service.get("published_ports", [])
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
if isinstance(service.get("published_ports"), list)
|
||||
and service.get("published_ports")
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -683,6 +710,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace create debian:12
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --network-policy egress
|
||||
pyro workspace create debian:12 --secret API_TOKEN=expected
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
|
|
@ -718,9 +746,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Time-to-live for the workspace before automatic cleanup.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--network",
|
||||
action="store_true",
|
||||
help="Enable outbound guest networking for the workspace guest.",
|
||||
"--network-policy",
|
||||
choices=("off", "egress", "egress+published-ports"),
|
||||
default="off",
|
||||
help="Workspace network policy.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--allow-host-compat",
|
||||
|
|
@ -1204,6 +1233,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready --publish 8080 -- \
|
||||
sh -lc 'touch .ready && python3 -m http.server 8080'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID app
|
||||
pyro workspace service logs WORKSPACE_ID app --tail-lines 50
|
||||
|
|
@ -1229,6 +1260,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app \
|
||||
--ready-file .ready --publish 18080:8080 -- \
|
||||
sh -lc 'touch .ready && python3 -m http.server 8080'
|
||||
pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \
|
||||
sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \
|
||||
while true; do sleep 60; done'
|
||||
|
|
@ -1280,6 +1314,16 @@ while true; do sleep 60; done'
|
|||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable for this service.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--publish",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="GUEST_PORT|HOST_PORT:GUEST_PORT",
|
||||
help=(
|
||||
"Publish one guest TCP port on 127.0.0.1. Requires workspace network policy "
|
||||
"`egress+published-ports`."
|
||||
),
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1528,6 +1572,33 @@ def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]:
|
|||
return parsed
|
||||
|
||||
|
||||
def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | None]]:
|
||||
parsed: list[dict[str, int | None]] = []
|
||||
for raw_value in values:
|
||||
candidate = raw_value.strip()
|
||||
if candidate == "":
|
||||
raise ValueError("published ports must not be empty")
|
||||
if ":" in candidate:
|
||||
raw_host_port, raw_guest_port = candidate.split(":", 1)
|
||||
try:
|
||||
host_port = int(raw_host_port)
|
||||
guest_port = int(raw_guest_port)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT"
|
||||
) from exc
|
||||
parsed.append({"host_port": host_port, "guest_port": guest_port})
|
||||
else:
|
||||
try:
|
||||
guest_port = int(candidate)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT"
|
||||
) from exc
|
||||
parsed.append({"host_port": None, "guest_port": guest_port})
|
||||
return parsed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
pyro = Pyro()
|
||||
|
|
@ -1634,7 +1705,7 @@ def main() -> None:
|
|||
vcpu_count=args.vcpu_count,
|
||||
mem_mib=args.mem_mib,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
network=args.network,
|
||||
network_policy=getattr(args, "network_policy", "off"),
|
||||
allow_host_compat=args.allow_host_compat,
|
||||
seed_path=args.seed_path,
|
||||
secrets=secrets or None,
|
||||
|
|
@ -1932,6 +2003,7 @@ def main() -> None:
|
|||
readiness = {"type": "command", "command": args.ready_command}
|
||||
command = _require_command(args.command_args)
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
published_ports = _parse_workspace_publish_options(getattr(args, "publish", []))
|
||||
try:
|
||||
payload = pyro.start_service(
|
||||
args.workspace_id,
|
||||
|
|
@ -1942,6 +2014,7 @@ def main() -> None:
|
|||
ready_timeout_seconds=args.ready_timeout_seconds,
|
||||
ready_interval_ms=args.ready_interval_ms,
|
||||
secret_env=secret_env or None,
|
||||
published_ports=published_ports or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue