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
|
|
@ -472,6 +472,7 @@ def test_cli_workspace_create_prints_json(
|
|||
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
|
||||
assert kwargs["environment"] == "debian:12"
|
||||
assert kwargs["seed_path"] == "./repo"
|
||||
assert kwargs["network_policy"] == "egress"
|
||||
return {"workspace_id": "workspace-123", "state": "started"}
|
||||
|
||||
class StubParser:
|
||||
|
|
@ -483,7 +484,7 @@ def test_cli_workspace_create_prints_json(
|
|||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
network_policy="egress",
|
||||
allow_host_compat=False,
|
||||
seed_path="./repo",
|
||||
json=True,
|
||||
|
|
@ -506,6 +507,7 @@ def test_cli_workspace_create_prints_human(
|
|||
"workspace_id": "workspace-123",
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"network_policy": "off",
|
||||
"workspace_path": "/workspace",
|
||||
"workspace_seed": {
|
||||
"mode": "directory",
|
||||
|
|
@ -530,7 +532,7 @@ def test_cli_workspace_create_prints_human(
|
|||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
network_policy="off",
|
||||
allow_host_compat=False,
|
||||
seed_path="/tmp/repo",
|
||||
json=False,
|
||||
|
|
@ -2047,12 +2049,21 @@ def test_cli_workspace_service_start_prints_json(
|
|||
assert service_name == "app"
|
||||
assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'"
|
||||
assert kwargs["readiness"] == {"type": "file", "path": ".ready"}
|
||||
assert kwargs["published_ports"] == [{"host_port": 18080, "guest_port": 8080}]
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"service_name": service_name,
|
||||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"execution_mode": "guest_vsock",
|
||||
"published_ports": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"host_port": 18080,
|
||||
"guest_port": 8080,
|
||||
"protocol": "tcp",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
class StartParser:
|
||||
|
|
@ -2070,6 +2081,7 @@ def test_cli_workspace_service_start_prints_json(
|
|||
ready_command=None,
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
publish=["18080:8080"],
|
||||
json=True,
|
||||
command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"],
|
||||
)
|
||||
|
|
@ -2149,6 +2161,14 @@ def test_cli_workspace_service_list_prints_human(
|
|||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"execution_mode": "guest_vsock",
|
||||
"published_ports": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"host_port": 18080,
|
||||
"guest_port": 8080,
|
||||
"protocol": "tcp",
|
||||
}
|
||||
],
|
||||
"readiness": {"type": "file", "path": "/workspace/.ready"},
|
||||
},
|
||||
{
|
||||
|
|
@ -2176,7 +2196,7 @@ def test_cli_workspace_service_list_prints_human(
|
|||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert "app [running] cwd=/workspace" in captured.out
|
||||
assert "app [running] cwd=/workspace published=127.0.0.1:18080->8080/tcp" in captured.out
|
||||
assert "worker [stopped] cwd=/workspace" in captured.out
|
||||
|
||||
|
||||
|
|
@ -3006,6 +3026,110 @@ def test_cli_workspace_secret_parsers_validate_syntax(tmp_path: Path) -> None:
|
|||
cli._parse_workspace_secret_env_options(["TOKEN", "TOKEN=API_TOKEN"]) # noqa: SLF001
|
||||
|
||||
|
||||
def test_cli_workspace_publish_parser_validates_syntax() -> None:
|
||||
assert cli._parse_workspace_publish_options(["8080"]) == [ # noqa: SLF001
|
||||
{"host_port": None, "guest_port": 8080}
|
||||
]
|
||||
assert cli._parse_workspace_publish_options(["18080:8080"]) == [ # noqa: SLF001
|
||||
{"host_port": 18080, "guest_port": 8080}
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
cli._parse_workspace_publish_options([" "]) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="must use GUEST_PORT or HOST_PORT:GUEST_PORT"):
|
||||
cli._parse_workspace_publish_options(["bad"]) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="must use GUEST_PORT or HOST_PORT:GUEST_PORT"):
|
||||
cli._parse_workspace_publish_options(["bad:8080"]) # noqa: SLF001
|
||||
|
||||
|
||||
def test_cli_workspace_service_start_rejects_multiple_readiness_flags_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def start_service(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("start_service should not be called")
|
||||
|
||||
class StartParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="service",
|
||||
workspace_service_command="start",
|
||||
workspace_id="workspace-123",
|
||||
service_name="app",
|
||||
cwd="/workspace",
|
||||
ready_file=".ready",
|
||||
ready_tcp=None,
|
||||
ready_http="http://127.0.0.1:8080/",
|
||||
ready_command=None,
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
publish=[],
|
||||
json=True,
|
||||
command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StartParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert "choose at most one" in payload["error"]
|
||||
|
||||
|
||||
def test_cli_workspace_service_start_prints_human_with_ready_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def start_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert service_name == "app"
|
||||
assert kwargs["readiness"] == {"type": "http", "url": "http://127.0.0.1:8080/ready"}
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"service_name": service_name,
|
||||
"state": "running",
|
||||
"cwd": "/workspace",
|
||||
"execution_mode": "guest_vsock",
|
||||
"readiness": kwargs["readiness"],
|
||||
}
|
||||
|
||||
class StartParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="service",
|
||||
workspace_service_command="start",
|
||||
workspace_id="workspace-123",
|
||||
service_name="app",
|
||||
cwd="/workspace",
|
||||
ready_file=None,
|
||||
ready_tcp=None,
|
||||
ready_http="http://127.0.0.1:8080/ready",
|
||||
ready_command=None,
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
publish=[],
|
||||
secret_env=[],
|
||||
json=False,
|
||||
command_args=["--", "sh", "-lc", "while true; do sleep 60; done"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StartParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert "workspace-service-start" in captured.err
|
||||
assert "service_name=app" in captured.err
|
||||
|
||||
|
||||
def test_print_workspace_summary_human_includes_secret_metadata(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue