Pivot persistent APIs to workspaces
Replace the public persistent-sandbox contract with workspace-first naming across CLI, SDK, MCP, payloads, and on-disk state. Rename the task surface to workspace equivalents, switch create-time seeding to `seed_path`, and store records under `workspaces/<workspace_id>/workspace.json` without carrying legacy task aliases or migrating old local task state. Keep `pyro run` and `vm_*` unchanged. Validation covered `uv lock`, focused public-contract/API/CLI/manager tests, `UV_CACHE_DIR=.uv-cache make check`, and `UV_CACHE_DIR=.uv-cache make dist-check`.
This commit is contained in:
parent
f57454bcb4
commit
48b82d8386
13 changed files with 743 additions and 618 deletions
|
|
@ -267,48 +267,48 @@ def test_vm_manager_run_vm(tmp_path: Path) -> None:
|
|||
assert str(result["stdout"]) == "ok\n"
|
||||
|
||||
|
||||
def test_task_lifecycle_and_logs(tmp_path: Path) -> None:
|
||||
def test_workspace_lifecycle_and_logs(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_task(
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
task_id = str(created["task_id"])
|
||||
workspace_id = str(created["workspace_id"])
|
||||
assert created["state"] == "started"
|
||||
assert created["workspace_path"] == "/workspace"
|
||||
|
||||
first = manager.exec_task(
|
||||
task_id,
|
||||
first = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="printf 'hello\\n' > note.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
second = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
|
||||
second = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
|
||||
assert first["exit_code"] == 0
|
||||
assert second["stdout"] == "hello\n"
|
||||
|
||||
status = manager.status_task(task_id)
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["command_count"] == 2
|
||||
assert status["last_command"] is not None
|
||||
|
||||
logs = manager.logs_task(task_id)
|
||||
logs = manager.logs_workspace(workspace_id)
|
||||
assert logs["count"] == 2
|
||||
entries = logs["entries"]
|
||||
assert isinstance(entries, list)
|
||||
assert entries[1]["stdout"] == "hello\n"
|
||||
|
||||
deleted = manager.delete_task(task_id)
|
||||
deleted = manager.delete_workspace(workspace_id)
|
||||
assert deleted["deleted"] is True
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
manager.status_task(task_id)
|
||||
manager.status_workspace(workspace_id)
|
||||
|
||||
|
||||
def test_task_create_seeds_directory_source_into_workspace(tmp_path: Path) -> None:
|
||||
def test_workspace_create_seeds_directory_source_into_workspace(tmp_path: Path) -> None:
|
||||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
|
@ -319,25 +319,25 @@ def test_task_create_seeds_directory_source_into_workspace(tmp_path: Path) -> No
|
|||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_task(
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=source_dir,
|
||||
seed_path=source_dir,
|
||||
)
|
||||
task_id = str(created["task_id"])
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert workspace_seed["mode"] == "directory"
|
||||
assert workspace_seed["source_path"] == str(source_dir.resolve())
|
||||
executed = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
|
||||
assert workspace_seed["seed_path"] == str(source_dir.resolve())
|
||||
executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
assert executed["stdout"] == "hello\n"
|
||||
|
||||
status = manager.status_task(task_id)
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["workspace_seed"]["mode"] == "directory"
|
||||
assert status["workspace_seed"]["source_path"] == str(source_dir.resolve())
|
||||
assert status["workspace_seed"]["seed_path"] == str(source_dir.resolve())
|
||||
|
||||
|
||||
def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
|
||||
def test_workspace_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "seed.tgz"
|
||||
nested_dir = tmp_path / "src"
|
||||
nested_dir.mkdir()
|
||||
|
|
@ -351,19 +351,19 @@ def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
|
|||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_task(
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=archive_path,
|
||||
seed_path=archive_path,
|
||||
)
|
||||
task_id = str(created["task_id"])
|
||||
workspace_id = str(created["workspace_id"])
|
||||
|
||||
assert created["workspace_seed"]["mode"] == "tar_archive"
|
||||
executed = manager.exec_task(task_id, command="cat note.txt", timeout_seconds=30)
|
||||
executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
|
||||
assert executed["stdout"] == "archive\n"
|
||||
|
||||
|
||||
def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
|
||||
def test_workspace_sync_push_updates_started_workspace(tmp_path: Path) -> None:
|
||||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
|
@ -377,26 +377,30 @@ def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
|
|||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_task(
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=source_dir,
|
||||
seed_path=source_dir,
|
||||
)
|
||||
task_id = str(created["task_id"])
|
||||
synced = manager.push_task_sync(task_id, source_path=update_dir, dest="subdir")
|
||||
workspace_id = str(created["workspace_id"])
|
||||
synced = manager.push_workspace_sync(workspace_id, source_path=update_dir, dest="subdir")
|
||||
|
||||
assert synced["workspace_sync"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
|
||||
executed = manager.exec_task(task_id, command="cat subdir/more.txt", timeout_seconds=30)
|
||||
executed = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command="cat subdir/more.txt",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
assert executed["stdout"] == "more\n"
|
||||
|
||||
status = manager.status_task(task_id)
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["command_count"] == 1
|
||||
assert status["workspace_seed"]["mode"] == "directory"
|
||||
|
||||
|
||||
def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
|
||||
def test_workspace_sync_push_requires_started_workspace(tmp_path: Path) -> None:
|
||||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
|
@ -410,22 +414,25 @@ def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
|
|||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
created = manager.create_task(
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=source_dir,
|
||||
seed_path=source_dir,
|
||||
)
|
||||
task_id = str(created["task_id"])
|
||||
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
|
||||
payload = json.loads(task_path.read_text(encoding="utf-8"))
|
||||
workspace_id = str(created["workspace_id"])
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["state"] = "stopped"
|
||||
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="must be in 'started' state before task_sync_push"):
|
||||
manager.push_task_sync(task_id, source_path=update_dir)
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="must be in 'started' state before workspace_sync_push",
|
||||
):
|
||||
manager.push_workspace_sync(workspace_id, source_path=update_dir)
|
||||
|
||||
|
||||
def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
|
||||
def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
|
||||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
|
@ -436,18 +443,18 @@ def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) ->
|
|||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
task_id = str(
|
||||
manager.create_task(
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["task_id"]
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="workspace destination must stay inside /workspace"):
|
||||
manager.push_task_sync(task_id, source_path=source_dir, dest="../escape")
|
||||
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
|
||||
|
||||
|
||||
def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
||||
def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "bad.tgz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
payload = b"bad\n"
|
||||
|
|
@ -462,15 +469,15 @@ def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
|||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unsafe archive member path"):
|
||||
manager.create_task(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=archive_path,
|
||||
seed_path=archive_path,
|
||||
)
|
||||
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
|
||||
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
|
||||
|
||||
|
||||
def test_task_create_rejects_archive_that_writes_through_symlink(tmp_path: Path) -> None:
|
||||
def test_workspace_create_rejects_archive_that_writes_through_symlink(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "bad-symlink.tgz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
symlink_info = tarfile.TarInfo(name="linked")
|
||||
|
|
@ -490,14 +497,14 @@ def test_task_create_rejects_archive_that_writes_through_symlink(tmp_path: Path)
|
|||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="traverse through a symlinked path"):
|
||||
manager.create_task(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=archive_path,
|
||||
seed_path=archive_path,
|
||||
)
|
||||
|
||||
|
||||
def test_task_create_cleans_up_on_seed_failure(
|
||||
def test_workspace_create_cleans_up_on_seed_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
source_dir = tmp_path / "seed"
|
||||
|
|
@ -517,27 +524,27 @@ def test_task_create_cleans_up_on_seed_failure(
|
|||
monkeypatch.setattr(manager._backend, "import_archive", _boom) # noqa: SLF001
|
||||
|
||||
with pytest.raises(RuntimeError, match="seed import failed"):
|
||||
manager.create_task(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
source_path=source_dir,
|
||||
seed_path=source_dir,
|
||||
)
|
||||
|
||||
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
|
||||
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
|
||||
|
||||
|
||||
def test_task_rehydrates_across_manager_processes(tmp_path: Path) -> None:
|
||||
def test_workspace_rehydrates_across_manager_processes(tmp_path: Path) -> None:
|
||||
base_dir = tmp_path / "vms"
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=base_dir,
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
task_id = str(
|
||||
manager.create_task(
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["task_id"]
|
||||
)["workspace_id"]
|
||||
)
|
||||
|
||||
other = VmManager(
|
||||
|
|
@ -545,33 +552,33 @@ def test_task_rehydrates_across_manager_processes(tmp_path: Path) -> None:
|
|||
base_dir=base_dir,
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
executed = other.exec_task(task_id, command="printf 'ok\\n'", timeout_seconds=30)
|
||||
executed = other.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30)
|
||||
assert executed["exit_code"] == 0
|
||||
assert executed["stdout"] == "ok\n"
|
||||
|
||||
logs = other.logs_task(task_id)
|
||||
logs = other.logs_workspace(workspace_id)
|
||||
assert logs["count"] == 1
|
||||
|
||||
|
||||
def test_task_requires_started_state(tmp_path: Path) -> None:
|
||||
def test_workspace_requires_started_state(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
task_id = str(
|
||||
manager.create_task(
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["task_id"]
|
||||
)["workspace_id"]
|
||||
)
|
||||
task_dir = tmp_path / "vms" / "tasks" / task_id / "task.json"
|
||||
payload = json.loads(task_dir.read_text(encoding="utf-8"))
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["state"] = "stopped"
|
||||
task_dir.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="must be in 'started' state"):
|
||||
manager.exec_task(task_id, command="true", timeout_seconds=30)
|
||||
manager.exec_workspace(workspace_id, command="true", timeout_seconds=30)
|
||||
|
||||
|
||||
def test_vm_manager_firecracker_backend_path(
|
||||
|
|
@ -708,7 +715,7 @@ def test_copy_rootfs_falls_back_to_copy2(
|
|||
assert dest.read_text(encoding="utf-8") == "payload"
|
||||
|
||||
|
||||
def test_task_create_cleans_up_on_start_failure(
|
||||
def test_workspace_create_cleans_up_on_start_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
|
|
@ -724,9 +731,9 @@ def test_task_create_cleans_up_on_start_failure(
|
|||
monkeypatch.setattr(manager._backend, "start", _boom) # noqa: SLF001
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
manager.create_task(environment="debian:12-base", allow_host_compat=True)
|
||||
manager.create_workspace(environment="debian:12-base", allow_host_compat=True)
|
||||
|
||||
assert list((tmp_path / "vms" / "tasks").iterdir()) == []
|
||||
assert list((tmp_path / "vms" / "workspaces").iterdir()) == []
|
||||
|
||||
|
||||
def test_exec_instance_wraps_guest_workspace_command(tmp_path: Path) -> None:
|
||||
|
|
@ -786,53 +793,53 @@ def test_exec_instance_wraps_guest_workspace_command(tmp_path: Path) -> None:
|
|||
assert captured["workdir"] is None
|
||||
|
||||
|
||||
def test_status_task_marks_dead_backing_process_stopped(tmp_path: Path) -> None:
|
||||
def test_status_workspace_marks_dead_backing_process_stopped(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
task_id = str(
|
||||
manager.create_task(
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["task_id"]
|
||||
)["workspace_id"]
|
||||
)
|
||||
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
|
||||
payload = json.loads(task_path.read_text(encoding="utf-8"))
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["metadata"]["execution_mode"] = "guest_vsock"
|
||||
payload["firecracker_pid"] = 999999
|
||||
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
status = manager.status_task(task_id)
|
||||
status = manager.status_workspace(workspace_id)
|
||||
assert status["state"] == "stopped"
|
||||
updated_payload = json.loads(task_path.read_text(encoding="utf-8"))
|
||||
updated_payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
assert "backing guest process" in str(updated_payload.get("last_error", ""))
|
||||
|
||||
|
||||
def test_reap_expired_tasks_removes_invalid_and_expired_records(tmp_path: Path) -> None:
|
||||
def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
invalid_dir = tmp_path / "vms" / "tasks" / "invalid"
|
||||
invalid_dir = tmp_path / "vms" / "workspaces" / "invalid"
|
||||
invalid_dir.mkdir(parents=True)
|
||||
(invalid_dir / "task.json").write_text("[]", encoding="utf-8")
|
||||
(invalid_dir / "workspace.json").write_text("[]", encoding="utf-8")
|
||||
|
||||
task_id = str(
|
||||
manager.create_task(
|
||||
workspace_id = str(
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)["task_id"]
|
||||
)["workspace_id"]
|
||||
)
|
||||
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
|
||||
payload = json.loads(task_path.read_text(encoding="utf-8"))
|
||||
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
|
||||
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
|
||||
payload["expires_at"] = 0.0
|
||||
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
with manager._lock: # noqa: SLF001
|
||||
manager._reap_expired_tasks_locked(time.time()) # noqa: SLF001
|
||||
manager._reap_expired_workspaces_locked(time.time()) # noqa: SLF001
|
||||
|
||||
assert not invalid_dir.exists()
|
||||
assert not (tmp_path / "vms" / "tasks" / task_id).exists()
|
||||
assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue