Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com> Reviewed-on: #13
This commit is contained in:
108
api/openapi.yaml
108
api/openapi.yaml
@ -1421,10 +1421,19 @@ paths:
|
|||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
- sessionAuth: []
|
- sessionAuth: []
|
||||||
description: |
|
description: |
|
||||||
Live snapshot: briefly pauses the capsule, writes its VM state +
|
Snapshot a capsule, processed asynchronously. The call returns
|
||||||
memory + flattened rootfs to a new template directory, then resumes
|
immediately with the capsule in the `snapshotting` state, then it
|
||||||
the capsule. The source capsule keeps running after the snapshot;
|
returns to its original state on completion. The capsule must be
|
||||||
the resulting template can be used to create new capsules.
|
`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
|
Snapshots are immutable: each call must use a fresh name. Re-using
|
||||||
an existing name returns 409 Conflict.
|
an existing name returns 409 Conflict.
|
||||||
@ -1435,14 +1444,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateSnapshotRequest"
|
$ref: "#/components/schemas/CreateSnapshotRequest"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"202":
|
||||||
description: Snapshot created
|
description: Snapshot accepted; capsule is now snapshotting
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Template"
|
$ref: "#/components/schemas/Capsule"
|
||||||
"409":
|
"409":
|
||||||
description: Name already exists or capsule not running
|
description: Name already exists, or capsule is not running or paused
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -2813,7 +2822,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Template"
|
$ref: "#/components/schemas/AdminTemplate"
|
||||||
|
|
||||||
/v1/admin/templates/{name}:
|
/v1/admin/templates/{name}:
|
||||||
delete:
|
delete:
|
||||||
@ -2899,6 +2908,26 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Cancelled
|
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:
|
/v1/admin/capsules:
|
||||||
post:
|
post:
|
||||||
summary: Create a capsule on behalf of any team (admin)
|
summary: Create a capsule on behalf of any team (admin)
|
||||||
@ -2969,6 +2998,10 @@ paths:
|
|||||||
summary: Create snapshot from any capsule (admin)
|
summary: Create snapshot from any capsule (admin)
|
||||||
operationId: adminCreateSnapshotFromCapsule
|
operationId: adminCreateSnapshotFromCapsule
|
||||||
tags: [admin]
|
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:
|
security:
|
||||||
- sessionAuth: []
|
- sessionAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -2977,21 +3010,22 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema: {type: string}
|
schema: {type: string}
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: false
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [name]
|
|
||||||
properties:
|
properties:
|
||||||
name: {type: string}
|
name:
|
||||||
|
type: string
|
||||||
|
description: Optional; an auto-generated name is used when omitted.
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"202":
|
||||||
description: Snapshot created
|
description: Snapshot accepted; capsule is now snapshotting
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Template"
|
$ref: "#/components/schemas/Capsule"
|
||||||
|
|
||||||
/v1/admin/capsules/{id}/exec:
|
/v1/admin/capsules/{id}/exec:
|
||||||
parameters:
|
parameters:
|
||||||
@ -3486,7 +3520,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
default: minimal
|
default: minimal-ubuntu
|
||||||
vcpus:
|
vcpus:
|
||||||
type: integer
|
type: integer
|
||||||
default: 1
|
default: 1
|
||||||
@ -3590,7 +3624,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
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:
|
template:
|
||||||
type: string
|
type: string
|
||||||
vcpus:
|
vcpus:
|
||||||
@ -3664,13 +3698,51 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: |
|
description: |
|
||||||
True when the template is platform-managed (visible to all teams,
|
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.
|
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:
|
metadata:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {type: string}
|
additionalProperties: {type: string}
|
||||||
nullable: true
|
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:
|
ExecRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [cmd]
|
required: [cmd]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@ -27,7 +27,6 @@ from wrenn.models._generated import (
|
|||||||
Status1,
|
Status1,
|
||||||
Template,
|
Template,
|
||||||
Type,
|
Type,
|
||||||
Type1,
|
|
||||||
Type2,
|
Type2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -60,6 +59,5 @@ __all__ = [
|
|||||||
"Status1",
|
"Status1",
|
||||||
"Template",
|
"Template",
|
||||||
"Type",
|
"Type",
|
||||||
"Type1",
|
|
||||||
"Type2",
|
"Type2",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-05-19T08:54:50+00:00
|
# timestamp: 2026-05-22T19:20:45+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||||
@ -65,7 +65,7 @@ class APIKeyResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreateCapsuleRequest(BaseModel):
|
class CreateCapsuleRequest(BaseModel):
|
||||||
template: str | None = "minimal"
|
template: str | None = "minimal-ubuntu"
|
||||||
vcpus: int | None = 1
|
vcpus: int | None = 1
|
||||||
memory_mb: int | None = 512
|
memory_mb: int | None = 512
|
||||||
disk_size_mb: Annotated[
|
disk_size_mb: Annotated[
|
||||||
@ -148,6 +148,7 @@ class Status(StrEnum):
|
|||||||
running = "running"
|
running = "running"
|
||||||
pausing = "pausing"
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
snapshotting = "snapshotting"
|
||||||
resuming = "resuming"
|
resuming = "resuming"
|
||||||
stopping = "stopping"
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
@ -203,12 +204,46 @@ class Template(BaseModel):
|
|||||||
platform: Annotated[
|
platform: Annotated[
|
||||||
bool | None,
|
bool | None,
|
||||||
Field(
|
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
|
] = None
|
||||||
metadata: dict[str, str] | None = 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):
|
class ExecRequest(BaseModel):
|
||||||
cmd: str
|
cmd: str
|
||||||
args: list[str] | None = None
|
args: list[str] | None = None
|
||||||
@ -296,7 +331,7 @@ class ListDirRequest(BaseModel):
|
|||||||
] = 1
|
] = 1
|
||||||
|
|
||||||
|
|
||||||
class Type1(StrEnum):
|
class Type2(StrEnum):
|
||||||
file = "file"
|
file = "file"
|
||||||
directory = "directory"
|
directory = "directory"
|
||||||
symlink = "symlink"
|
symlink = "symlink"
|
||||||
@ -305,7 +340,7 @@ class Type1(StrEnum):
|
|||||||
class FileEntry(BaseModel):
|
class FileEntry(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
type: Type1 | None = None
|
type: Type2 | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
mode: int | None = None
|
mode: int | None = None
|
||||||
permissions: Annotated[
|
permissions: Annotated[
|
||||||
@ -333,7 +368,7 @@ class RemoveRequest(BaseModel):
|
|||||||
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
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.
|
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
||||||
"""
|
"""
|
||||||
@ -344,7 +379,7 @@ class Type2(StrEnum):
|
|||||||
|
|
||||||
class CreateHostRequest(BaseModel):
|
class CreateHostRequest(BaseModel):
|
||||||
type: Annotated[
|
type: Annotated[
|
||||||
Type2,
|
Type3,
|
||||||
Field(
|
Field(
|
||||||
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
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).")]
|
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
||||||
|
|
||||||
|
|
||||||
class Type3(StrEnum):
|
class Type4(StrEnum):
|
||||||
regular = "regular"
|
regular = "regular"
|
||||||
byoc = "byoc"
|
byoc = "byoc"
|
||||||
|
|
||||||
@ -387,7 +422,7 @@ class Status1(StrEnum):
|
|||||||
|
|
||||||
class Host(BaseModel):
|
class Host(BaseModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
type: Type3 | None = None
|
type: Type4 | None = None
|
||||||
team_id: str | None = None
|
team_id: str | None = None
|
||||||
provider: str | None = None
|
provider: str | None = None
|
||||||
availability_zone: str | None = None
|
availability_zone: str | None = None
|
||||||
@ -678,14 +713,14 @@ class Resource(BaseModel):
|
|||||||
type: str | None = None
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Type4(StrEnum):
|
class Type5(StrEnum):
|
||||||
user = "user"
|
user = "user"
|
||||||
api_key = "api_key"
|
api_key = "api_key"
|
||||||
system = "system"
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
class Actor(BaseModel):
|
class Actor(BaseModel):
|
||||||
type: Type4 | None = None
|
type: Type5 | None = None
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
||||||
|
|||||||
@ -74,32 +74,32 @@ class TestFilesList:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "main.py",
|
"name": "main.py",
|
||||||
"path": "/home/user/main.py",
|
"path": "/home/wrenn-user/main.py",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"mode": 33188,
|
"mode": 33188,
|
||||||
"permissions": "-rw-r--r--",
|
"permissions": "-rw-r--r--",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "config",
|
"name": "config",
|
||||||
"path": "/home/user/config",
|
"path": "/home/wrenn-user/config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899100,
|
"modified_at": 1712899100,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entries = cap.files.list("/home/user")
|
entries = cap.files.list("/home/wrenn-user")
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
assert isinstance(entries[0], FileEntry)
|
assert isinstance(entries[0], FileEntry)
|
||||||
assert entries[0].name == "main.py"
|
assert entries[0].name == "main.py"
|
||||||
@ -113,7 +113,7 @@ class TestFilesList:
|
|||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||||
200, json={"entries": []}
|
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)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["depth"] == 3
|
assert body["depth"] == 3
|
||||||
|
|
||||||
@ -136,19 +136,19 @@ class TestFilesMakeDir:
|
|||||||
json={
|
json={
|
||||||
"entry": {
|
"entry": {
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"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 isinstance(entry, FileEntry)
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
assert entry.type == "directory"
|
assert entry.type == "directory"
|
||||||
@ -166,20 +166,20 @@ class TestFilesMakeDir:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/user/data")
|
entry = cap.files.make_dir("/home/wrenn-user/data")
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
|
|
||||||
|
|
||||||
@ -188,7 +188,7 @@ class TestFilesRemove:
|
|||||||
def test_remove_succeeds(self):
|
def test_remove_succeeds(self):
|
||||||
cap = _make_capsule()
|
cap = _make_capsule()
|
||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
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
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@ -411,7 +411,7 @@ class TestPtySessionSendStart:
|
|||||||
cols=120,
|
cols=120,
|
||||||
rows=40,
|
rows=40,
|
||||||
envs={"TERM": "xterm-256color"},
|
envs={"TERM": "xterm-256color"},
|
||||||
cwd="/home/user",
|
cwd="/home/wrenn-user",
|
||||||
)
|
)
|
||||||
sent = json.loads(ws.send_text.call_args[0][0])
|
sent = json.loads(ws.send_text.call_args[0][0])
|
||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
|
|||||||
@ -323,7 +323,7 @@ class TestFiles:
|
|||||||
class TestGit:
|
class TestGit:
|
||||||
"""Shared capsule for git operation tests.
|
"""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.
|
does not support the cwd parameter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -344,14 +344,14 @@ class TestGit:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_init_created_repo(self):
|
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):
|
def test_status_clean(self):
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
|
|
||||||
def test_add_and_commit(self):
|
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)
|
self.capsule.git.add(all=True)
|
||||||
result = self.capsule.git.commit("initial commit")
|
result = self.capsule.git.commit("initial commit")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@ -361,14 +361,14 @@ class TestGit:
|
|||||||
assert status.is_clean
|
assert status.is_clean
|
||||||
|
|
||||||
def test_status_with_changes(self):
|
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:
|
try:
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert not status.is_clean
|
assert not status.is_clean
|
||||||
paths = [f.path for f in status.files]
|
paths = [f.path for f in status.files]
|
||||||
assert "dirty.txt" in paths
|
assert "dirty.txt" in paths
|
||||||
finally:
|
finally:
|
||||||
self.capsule.files.remove("/root/dirty.txt")
|
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
|
||||||
|
|
||||||
def test_branches(self):
|
def test_branches(self):
|
||||||
branches = self.capsule.git.branches()
|
branches = self.capsule.git.branches()
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class TestCommandEnvironment:
|
|||||||
|
|
||||||
def test_default_cwd_is_home(self):
|
def test_default_cwd_is_home(self):
|
||||||
result = self.capsule.commands.run("pwd")
|
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):
|
def test_cwd_resolves_relative_paths(self):
|
||||||
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
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.
|
# Each run is a fresh process — `cd` in one does not affect the next.
|
||||||
self.capsule.commands.run("cd /tmp")
|
self.capsule.commands.run("cd /tmp")
|
||||||
result = self.capsule.commands.run("pwd")
|
result = self.capsule.commands.run("pwd")
|
||||||
assert result.stdout.strip() == "/root"
|
assert result.stdout.strip() == "/home/wrenn-user"
|
||||||
|
|
||||||
def test_single_env_var(self):
|
def test_single_env_var(self):
|
||||||
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
||||||
@ -115,9 +115,29 @@ class TestCommandEnvironment:
|
|||||||
def test_base_environment_present(self):
|
def test_base_environment_present(self):
|
||||||
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
||||||
lines = result.stdout.strip().splitlines()
|
lines = result.stdout.strip().splitlines()
|
||||||
assert lines[0] == "/root"
|
assert lines[0] == "/home/wrenn-user"
|
||||||
assert "/usr/bin" in lines[1]
|
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
|
# Long-running commands
|
||||||
@ -143,7 +163,7 @@ class TestLongRunningCommands:
|
|||||||
|
|
||||||
def test_apt_get_install(self):
|
def test_apt_get_install(self):
|
||||||
result = self.capsule.commands.run(
|
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
|
assert result.exit_code == 0
|
||||||
|
|
||||||
@ -388,7 +408,9 @@ class TestGitClone:
|
|||||||
def setup_class(cls):
|
def setup_class(cls):
|
||||||
_ensure_env()
|
_ensure_env()
|
||||||
cls.capsule = Capsule(wait=True)
|
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
|
@classmethod
|
||||||
def teardown_class(cls):
|
def teardown_class(cls):
|
||||||
@ -398,66 +420,74 @@ class TestGitClone:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_clone_created_repo(self):
|
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):
|
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]
|
names = [e.name for e in entries]
|
||||||
assert "README.md" in names
|
assert "README.md" in names
|
||||||
|
|
||||||
def test_status_of_clone_is_clean(self):
|
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.branch == "main"
|
||||||
assert status.is_clean
|
assert status.is_clean
|
||||||
|
|
||||||
def test_branches_lists_main(self):
|
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]
|
names = [b.name for b in branches]
|
||||||
assert "main" in names
|
assert "main" in names
|
||||||
assert any(b.is_current for b in branches)
|
assert any(b.is_current for b in branches)
|
||||||
|
|
||||||
def test_remote_get_origin(self):
|
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 url is not None
|
||||||
assert "wrennhq/wrenn" in url
|
assert "wrennhq/wrenn" in url
|
||||||
|
|
||||||
def test_git_log_has_commit(self):
|
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.exit_code == 0
|
||||||
assert result.stdout.strip()
|
assert result.stdout.strip()
|
||||||
|
|
||||||
def test_modify_add_commit(self):
|
def test_modify_add_commit(self):
|
||||||
marker = uuid.uuid4().hex
|
marker = uuid.uuid4().hex
|
||||||
self.capsule.git.configure_user(
|
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.files.write(
|
||||||
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
|
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
|
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
|
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.is_clean
|
||||||
assert after.ahead >= 1
|
assert after.ahead >= 1
|
||||||
|
|
||||||
def test_create_and_checkout_branch_in_clone(self):
|
def test_create_and_checkout_branch_in_clone(self):
|
||||||
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
|
self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
|
||||||
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
||||||
current = [b for b in branches if b.is_current]
|
current = [b for b in branches if b.is_current]
|
||||||
assert current and current[0].name == "sdk-feature"
|
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):
|
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:
|
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
|
assert "README.md" in result.stdout
|
||||||
finally:
|
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:
|
class TestGitErrors:
|
||||||
@ -481,7 +511,7 @@ class TestGitErrors:
|
|||||||
with pytest.raises(GitError):
|
with pytest.raises(GitError):
|
||||||
self.capsule.git.clone(
|
self.capsule.git.clone(
|
||||||
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
||||||
"/root/missing",
|
"/home/wrenn-user/missing",
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -493,7 +523,11 @@ class TestGitErrors:
|
|||||||
|
|
||||||
def test_clone_with_branch(self):
|
def test_clone_with_branch(self):
|
||||||
self.capsule.git.clone(
|
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"
|
assert status.branch == "main"
|
||||||
|
|||||||
Reference in New Issue
Block a user