From de72dfe9c880530afa79f30bb7a8cf398b42fe73 Mon Sep 17 00:00:00 2001 From: "Rafeed M. Bhuiyan" Date: Fri, 22 May 2026 23:01:46 +0000 Subject: [PATCH] v0.1.5 (#13) Co-authored-by: Tasnim Kabir Sadik Reviewed-on: https://git.omukk.dev/wrenn/python-sdk/pulls/13 --- api/openapi.yaml | 108 ++++++++++++++++++++++++----- pyproject.toml | 2 +- src/wrenn/models/__init__.py | 2 - src/wrenn/models/_generated.py | 57 ++++++++++++--- tests/test_filesystem_pty.py | 36 +++++----- tests/test_integration.py | 10 +-- tests/test_integration_advanced.py | 86 ++++++++++++++++------- uv.lock | 2 +- 8 files changed, 221 insertions(+), 82 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f3fb110..c8ad59f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1421,10 +1421,19 @@ paths: - apiKeyAuth: [] - sessionAuth: [] description: | - Live snapshot: briefly pauses the capsule, writes its VM state + - memory + flattened rootfs to a new template directory, then resumes - the capsule. The source capsule keeps running after the snapshot; - the resulting template can be used to create new capsules. + Snapshot a capsule, processed asynchronously. The call returns + immediately with the capsule in the `snapshotting` state, then it + returns to its original state on completion. The capsule must be + `running` or `paused`. + + A `running` capsule is snapshotted live: it briefly pauses while its VM + state + memory + flattened rootfs are written to a new template, then + resumes to `running`. A `paused` capsule is snapshotted directly from + its on-disk state without reviving the VM, and stays `paused`. + + Because it is async, the response does NOT contain the template. Watch + for the `template.snapshot.create` SSE event (its `outcome` reports + success or failure) or poll `GET /v1/snapshots` to observe completion. Snapshots are immutable: each call must use a fresh name. Re-using an existing name returns 409 Conflict. @@ -1435,14 +1444,14 @@ paths: schema: $ref: "#/components/schemas/CreateSnapshotRequest" responses: - "201": - description: Snapshot created + "202": + description: Snapshot accepted; capsule is now snapshotting content: application/json: schema: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/Capsule" "409": - description: Name already exists or capsule not running + description: Name already exists, or capsule is not running or paused content: application/json: schema: @@ -2813,7 +2822,7 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/AdminTemplate" /v1/admin/templates/{name}: delete: @@ -2899,6 +2908,26 @@ paths: "204": description: Cancelled + /v1/admin/builds/{id}/stream: + get: + summary: Stream a build's live console (admin, WebSocket) + description: > + WebSocket endpoint. On connect, replays the completed-step history, + then live-tails JSON events (step-start, output, step-end, + build-status, ping) until the build finishes. + operationId: adminStreamBuild + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + responses: + "101": + description: WebSocket upgrade — streams build console events + /v1/admin/capsules: post: summary: Create a capsule on behalf of any team (admin) @@ -2969,6 +2998,10 @@ paths: summary: Create snapshot from any capsule (admin) operationId: adminCreateSnapshotFromCapsule tags: [admin] + description: | + Snapshots a `running` or `paused` capsule into a platform template, + processed asynchronously (see `POST /v1/snapshots`). A running capsule + resumes to `running`; a paused capsule stays `paused`. security: - sessionAuth: [] parameters: @@ -2977,21 +3010,22 @@ paths: required: true schema: {type: string} requestBody: - required: true + required: false content: application/json: schema: type: object - required: [name] properties: - name: {type: string} + name: + type: string + description: Optional; an auto-generated name is used when omitted. responses: - "201": - description: Snapshot created + "202": + description: Snapshot accepted; capsule is now snapshotting content: application/json: schema: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/Capsule" /v1/admin/capsules/{id}/exec: parameters: @@ -3486,7 +3520,7 @@ components: properties: template: type: string - default: minimal + default: minimal-ubuntu vcpus: type: integer default: 1 @@ -3590,7 +3624,7 @@ components: type: string status: type: string - enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] + enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error] template: type: string vcpus: @@ -3664,13 +3698,51 @@ components: type: boolean description: | True when the template is platform-managed (visible to all teams, - e.g. the built-in `minimal` rootfs). False for team-owned + e.g. the built-in `minimal-ubuntu` rootfs). False for team-owned snapshot templates. + protected: + type: boolean + description: | + True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. metadata: type: object additionalProperties: {type: string} nullable: true + AdminTemplate: + type: object + description: | + Template as returned by the admin templates list. Unlike `Template` + (the team-facing snapshot shape), this includes the owning `team_id` + and omits `platform`/`metadata`. + properties: + name: + type: string + type: + type: string + enum: [base, snapshot] + vcpus: + type: integer + memory_mb: + type: integer + size_bytes: + type: integer + format: int64 + team_id: + type: string + description: Owning team ID (formatted, e.g. `team-…`). Platform team for global templates. + created_at: + type: string + format: date-time + protected: + type: boolean + description: | + True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. + ExecRequest: type: object required: [cmd] diff --git a/pyproject.toml b/pyproject.toml index 1b78b84..6ee4a11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.4" +version = "0.1.5" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 6fe5eb8..52bdc62 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -27,7 +27,6 @@ from wrenn.models._generated import ( Status1, Template, Type, - Type1, Type2, ) @@ -60,6 +59,5 @@ __all__ = [ "Status1", "Template", "Type", - "Type1", "Type2", ] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 8eb7425..e78331f 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-19T08:54:50+00:00 +# timestamp: 2026-05-22T19:20:45+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field @@ -65,7 +65,7 @@ class APIKeyResponse(BaseModel): class CreateCapsuleRequest(BaseModel): - template: str | None = "minimal" + template: str | None = "minimal-ubuntu" vcpus: int | None = 1 memory_mb: int | None = 512 disk_size_mb: Annotated[ @@ -148,6 +148,7 @@ class Status(StrEnum): running = "running" pausing = "pausing" paused = "paused" + snapshotting = "snapshotting" resuming = "resuming" stopping = "stopping" hibernated = "hibernated" @@ -203,12 +204,46 @@ class Template(BaseModel): platform: Annotated[ bool | None, Field( - description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n" + description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n" + ), + ] = None + protected: Annotated[ + bool | None, + Field( + description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n" ), ] = None metadata: dict[str, str] | None = None +class AdminTemplate(BaseModel): + """ + Template as returned by the admin templates list. Unlike `Template` + (the team-facing snapshot shape), this includes the owning `team_id` + and omits `platform`/`metadata`. + + """ + + name: str | None = None + type: Type | None = None + vcpus: int | None = None + memory_mb: int | None = None + size_bytes: int | None = None + team_id: Annotated[ + str | None, + Field( + description="Owning team ID (formatted, e.g. `team-…`). Platform team for global templates." + ), + ] = None + created_at: AwareDatetime | None = None + protected: Annotated[ + bool | None, + Field( + description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n" + ), + ] = None + + class ExecRequest(BaseModel): cmd: str args: list[str] | None = None @@ -296,7 +331,7 @@ class ListDirRequest(BaseModel): ] = 1 -class Type1(StrEnum): +class Type2(StrEnum): file = "file" directory = "directory" symlink = "symlink" @@ -305,7 +340,7 @@ class Type1(StrEnum): class FileEntry(BaseModel): name: str | None = None path: str | None = None - type: Type1 | None = None + type: Type2 | None = None size: int | None = None mode: int | None = None permissions: Annotated[ @@ -333,7 +368,7 @@ class RemoveRequest(BaseModel): path: Annotated[str, Field(description="Path to remove inside the capsule")] -class Type2(StrEnum): +class Type3(StrEnum): """ Host type. Regular hosts are shared; BYOC hosts belong to a team. """ @@ -344,7 +379,7 @@ class Type2(StrEnum): class CreateHostRequest(BaseModel): type: Annotated[ - Type2, + Type3, Field( description="Host type. Regular hosts are shared; BYOC hosts belong to a team." ), @@ -372,7 +407,7 @@ class RegisterHostRequest(BaseModel): address: Annotated[str, Field(description="Host agent address (ip:port).")] -class Type3(StrEnum): +class Type4(StrEnum): regular = "regular" byoc = "byoc" @@ -387,7 +422,7 @@ class Status1(StrEnum): class Host(BaseModel): id: str | None = None - type: Type3 | None = None + type: Type4 | None = None team_id: str | None = None provider: str | None = None availability_zone: str | None = None @@ -678,14 +713,14 @@ class Resource(BaseModel): type: str | None = None -class Type4(StrEnum): +class Type5(StrEnum): user = "user" api_key = "api_key" system = "system" class Actor(BaseModel): - type: Type4 | None = None + type: Type5 | None = None id: str | None = None name: str | None = None diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 2ce3f40..1c963b9 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -74,32 +74,32 @@ class TestFilesList: "entries": [ { "name": "main.py", - "path": "/home/user/main.py", + "path": "/home/wrenn-user/main.py", "type": "file", "size": 1024, "mode": 33188, "permissions": "-rw-r--r--", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, }, { "name": "config", - "path": "/home/user/config", + "path": "/home/wrenn-user/config", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899100, "symlink_target": None, }, ] }, ) - entries = cap.files.list("/home/user") + entries = cap.files.list("/home/wrenn-user") assert len(entries) == 2 assert isinstance(entries[0], FileEntry) assert entries[0].name == "main.py" @@ -113,7 +113,7 @@ class TestFilesList: route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( 200, json={"entries": []} ) - cap.files.list("/home/user", depth=3) + cap.files.list("/home/wrenn-user", depth=3) body = json.loads(route.calls[0].request.content) assert body["depth"] == 3 @@ -136,19 +136,19 @@ class TestFilesMakeDir: json={ "entry": { "name": "data", - "path": "/home/user/data", + "path": "/home/wrenn-user/data", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, } }, ) - entry = cap.files.make_dir("/home/user/data") + entry = cap.files.make_dir("/home/wrenn-user/data") assert isinstance(entry, FileEntry) assert entry.name == "data" assert entry.type == "directory" @@ -166,20 +166,20 @@ class TestFilesMakeDir: "entries": [ { "name": "data", - "path": "/home/user/data", + "path": "/home/wrenn-user/data", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, } ] }, ) - entry = cap.files.make_dir("/home/user/data") + entry = cap.files.make_dir("/home/wrenn-user/data") assert entry.name == "data" @@ -188,7 +188,7 @@ class TestFilesRemove: def test_remove_succeeds(self): cap = _make_capsule() route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) - cap.files.remove("/home/user/old_data") + cap.files.remove("/home/wrenn-user/old_data") assert route.called @respx.mock @@ -411,7 +411,7 @@ class TestPtySessionSendStart: cols=120, rows=40, envs={"TERM": "xterm-256color"}, - cwd="/home/user", + cwd="/home/wrenn-user", ) sent = json.loads(ws.send_text.call_args[0][0]) assert sent["cmd"] == "/bin/zsh" diff --git a/tests/test_integration.py b/tests/test_integration.py index 49eaab7..358065e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -323,7 +323,7 @@ class TestFiles: class TestGit: """Shared capsule for git operation tests. - Initializes a repo at /root (default cwd) since the exec API + Initializes a repo at /home/wrenn-user (default cwd) since the exec API does not support the cwd parameter. """ @@ -344,14 +344,14 @@ class TestGit: pass def test_init_created_repo(self): - assert self.capsule.files.exists("/root/.git") + assert self.capsule.files.exists("/home/wrenn-user/.git") def test_status_clean(self): status = self.capsule.git.status() assert status.branch == "main" def test_add_and_commit(self): - self.capsule.files.write("/root/hello.txt", "hello git") + self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git") self.capsule.git.add(all=True) result = self.capsule.git.commit("initial commit") assert result.exit_code == 0 @@ -361,14 +361,14 @@ class TestGit: assert status.is_clean def test_status_with_changes(self): - self.capsule.files.write("/root/dirty.txt", "uncommitted") + self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted") try: status = self.capsule.git.status() assert not status.is_clean paths = [f.path for f in status.files] assert "dirty.txt" in paths finally: - self.capsule.files.remove("/root/dirty.txt") + self.capsule.files.remove("/home/wrenn-user/dirty.txt") def test_branches(self): branches = self.capsule.git.branches() diff --git a/tests/test_integration_advanced.py b/tests/test_integration_advanced.py index 3f5e343..12c7da1 100644 --- a/tests/test_integration_advanced.py +++ b/tests/test_integration_advanced.py @@ -75,7 +75,7 @@ class TestCommandEnvironment: def test_default_cwd_is_home(self): result = self.capsule.commands.run("pwd") - assert result.stdout.strip() == "/root" + assert result.stdout.strip() == "/home/wrenn-user" def test_cwd_resolves_relative_paths(self): self.capsule.files.make_dir("/tmp/cwd_probe/sub") @@ -90,7 +90,7 @@ class TestCommandEnvironment: # Each run is a fresh process — `cd` in one does not affect the next. self.capsule.commands.run("cd /tmp") result = self.capsule.commands.run("pwd") - assert result.stdout.strip() == "/root" + assert result.stdout.strip() == "/home/wrenn-user" def test_single_env_var(self): result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) @@ -115,9 +115,29 @@ class TestCommandEnvironment: def test_base_environment_present(self): result = self.capsule.commands.run("echo $HOME; echo $PATH") lines = result.stdout.strip().splitlines() - assert lines[0] == "/root" + assert lines[0] == "/home/wrenn-user" assert "/usr/bin" in lines[1] + def test_sudo_available(self): + result = self.capsule.commands.run("which sudo") + assert result.exit_code == 0 + + def test_sudo_runs_without_password(self): + result = self.capsule.commands.run("sudo whoami") + assert result.exit_code == 0 + assert result.stdout.strip() == "root" + + def test_sudo_can_write_to_protected_path(self): + result = self.capsule.commands.run( + "sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker" + ) + assert result.exit_code == 0 + + def test_sudo_can_read_root_owned_file(self): + result = self.capsule.commands.run("sudo cat /etc/shadow | head -1") + assert result.exit_code == 0 + assert "root" in result.stdout + # ══════════════════════════════════════════════════════════════════ # Long-running commands @@ -143,7 +163,7 @@ class TestLongRunningCommands: def test_apt_get_install(self): result = self.capsule.commands.run( - "apt-get update -qq && apt-get install -y -qq cowsay", timeout=300 + "sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300 ) assert result.exit_code == 0 @@ -388,7 +408,9 @@ class TestGitClone: def setup_class(cls): _ensure_env() cls.capsule = Capsule(wait=True) - cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300) + cls.capsule.git.clone( + WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300 + ) @classmethod def teardown_class(cls): @@ -398,66 +420,74 @@ class TestGitClone: pass def test_clone_created_repo(self): - assert self.capsule.files.exists("/root/wrenn/.git") + assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git") def test_clone_checked_out_files(self): - entries = self.capsule.files.list("/root/wrenn") + entries = self.capsule.files.list("/home/wrenn-user/wrenn") names = [e.name for e in entries] assert "README.md" in names def test_status_of_clone_is_clean(self): - status = self.capsule.git.status(cwd="/root/wrenn") + status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert status.branch == "main" assert status.is_clean def test_branches_lists_main(self): - branches = self.capsule.git.branches(cwd="/root/wrenn") + branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn") names = [b.name for b in branches] assert "main" in names assert any(b.is_current for b in branches) def test_remote_get_origin(self): - url = self.capsule.git.remote_get("origin", cwd="/root/wrenn") + url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn") assert url is not None assert "wrennhq/wrenn" in url def test_git_log_has_commit(self): - result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn") + result = self.capsule.commands.run( + "git log --oneline -1", cwd="/home/wrenn-user/wrenn" + ) assert result.exit_code == 0 assert result.stdout.strip() def test_modify_add_commit(self): marker = uuid.uuid4().hex self.capsule.git.configure_user( - "CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local" + "CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local" ) - self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker) - self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") + self.capsule.files.write( + f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker + ) + self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn") - staged = self.capsule.git.status(cwd="/root/wrenn") + staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert staged.has_staged - result = self.capsule.git.commit("probe commit", cwd="/root/wrenn") + result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn") assert result.exit_code == 0 - after = self.capsule.git.status(cwd="/root/wrenn") + after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert after.is_clean assert after.ahead >= 1 def test_create_and_checkout_branch_in_clone(self): - self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") - branches = self.capsule.git.branches(cwd="/root/wrenn") + self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn") + branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn") current = [b for b in branches if b.is_current] assert current and current[0].name == "sdk-feature" - self.capsule.git.checkout_branch("main", cwd="/root/wrenn") + self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn") def test_diff_via_commands(self): - self.capsule.files.write("/root/wrenn/README.md", "overwritten\n") + self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n") try: - result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn") + result = self.capsule.commands.run( + "git diff --stat", cwd="/home/wrenn-user/wrenn" + ) assert "README.md" in result.stdout finally: - self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn") + self.capsule.git.restore( + ["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn" + ) class TestGitErrors: @@ -481,7 +511,7 @@ class TestGitErrors: with pytest.raises(GitError): self.capsule.git.clone( "https://github.com/wrennhq/this-repo-does-not-exist-xyz", - "/root/missing", + "/home/wrenn-user/missing", timeout=120, ) @@ -493,7 +523,11 @@ class TestGitErrors: def test_clone_with_branch(self): self.capsule.git.clone( - WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300 + WRENN_REPO, + "/home/wrenn-user/wrenn-main", + branch="main", + depth=1, + timeout=300, ) - status = self.capsule.git.status(cwd="/root/wrenn-main") + status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main") assert status.branch == "main" diff --git a/uv.lock b/uv.lock index d35d4ae..bc19b60 100644 --- a/uv.lock +++ b/uv.lock @@ -1166,7 +1166,7 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.4" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "certifi" },