Compare commits
51 Commits
main
...
98028bab52
| Author | SHA1 | Date | |
|---|---|---|---|
| 98028bab52 | |||
| 7291dbe669 | |||
| 8a62b6207c | |||
| db48e3cfbf | |||
| fbcedc5317 | |||
| 005871441a | |||
| b2ec7f9ab3 | |||
| 9edde7bff5 | |||
| 369c75af24 | |||
| 41ee41e9cd | |||
| fce514c49c | |||
| 87cc16e9e2 | |||
| 08f6a1ab84 | |||
| 51c6987515 | |||
| e057ec2407 | |||
| e5e4e1a85b | |||
| 6112c71abc | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 | |||
| aa9477ffe8 | |||
| 2bb3dbd71d | |||
| 3f26a2fbcf | |||
| 2faf0dd0ae | |||
| 68c7d0de42 | |||
| ad64c85393 | |||
| bab53aedbe | |||
| 82e181dd7e | |||
| ee1f55635f | |||
| 6bdf28e2ae | |||
| 61bc040098 | |||
| 7b35ffb60c | |||
| 42bcc792d6 | |||
| 3f97c73b2f | |||
| 7e7ecbd48a | |||
| 7b9a06d1b5 | |||
| 3d0eda5c60 | |||
| eecf1dc65b | |||
| 3cced768a4 | |||
| 0ac9bf79ee | |||
| bf5914c0a8 | |||
| 976af9a209 | |||
| f3fd6865f9 | |||
| 340ed46df6 | |||
| a5bf66c199 | |||
| f51a962fff |
54
README.md
54
README.md
@ -172,8 +172,6 @@ import sys
|
|||||||
# Stream a new command
|
# Stream a new command
|
||||||
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
||||||
match event.type:
|
match event.type:
|
||||||
case "start":
|
|
||||||
print(f"PID: {event.pid}")
|
|
||||||
case "stdout":
|
case "stdout":
|
||||||
print(event.data, end="")
|
print(event.data, end="")
|
||||||
case "stderr":
|
case "stderr":
|
||||||
@ -183,11 +181,8 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
|||||||
|
|
||||||
# Connect to a running background process
|
# Connect to a running background process
|
||||||
for event in capsule.commands.connect(handle.pid):
|
for event in capsule.commands.connect(handle.pid):
|
||||||
match event.type:
|
if event.type == "stdout":
|
||||||
case "start":
|
print(event.data, end="")
|
||||||
print(f"PID: {event.pid}")
|
|
||||||
case "stdout":
|
|
||||||
print(event.data, end="")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Process Management
|
#### Process Management
|
||||||
@ -216,7 +211,6 @@ capsule.files.exists("/app/main.py") # True
|
|||||||
|
|
||||||
# List directory
|
# List directory
|
||||||
entries = capsule.files.list("/home/user", depth=1)
|
entries = capsule.files.list("/home/user", depth=1)
|
||||||
# FileEntry has: name, type (file/dir), size, modified_at
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
print(entry.name, entry.type, entry.size)
|
print(entry.name, entry.type, entry.size)
|
||||||
|
|
||||||
@ -295,27 +289,8 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
|
|||||||
|
|
||||||
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
||||||
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
||||||
|
|
||||||
# Reset and restore
|
|
||||||
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
|
|
||||||
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Persistent Credential Store
|
|
||||||
|
|
||||||
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
|
|
||||||
|
|
||||||
```python
|
|
||||||
capsule.git.dangerously_authenticate(
|
|
||||||
username="user",
|
|
||||||
password="ghp_token",
|
|
||||||
host="github.com",
|
|
||||||
protocol="https",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
|
|
||||||
|
|
||||||
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@ -333,7 +308,7 @@ except GitAuthError as e:
|
|||||||
```python
|
```python
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
|
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
||||||
term.write(b"ls -la\n")
|
term.write(b"ls -la\n")
|
||||||
for event in term:
|
for event in term:
|
||||||
if event.type == "output":
|
if event.type == "output":
|
||||||
@ -476,10 +451,9 @@ result = capsule.run_code("print('running on custom template')")
|
|||||||
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
||||||
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
||||||
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
||||||
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
|
|
||||||
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
||||||
|
|
||||||
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
||||||
|
|
||||||
### Code Runner + Commands/Files
|
### Code Runner + Commands/Files
|
||||||
|
|
||||||
@ -553,15 +527,15 @@ The SDK maps server error codes to typed exceptions:
|
|||||||
```python
|
```python
|
||||||
from wrenn import (
|
from wrenn import (
|
||||||
WrennError,
|
WrennError,
|
||||||
WrennValidationError, # 400
|
WrennValidationError, # 400
|
||||||
WrennAuthenticationError, # 401
|
WrennAuthenticationError, # 401
|
||||||
WrennForbiddenError, # 403
|
WrennForbiddenError, # 403
|
||||||
WrennNotFoundError, # 404
|
WrennNotFoundError, # 404
|
||||||
WrennConflictError, # 409
|
WrennConflictError, # 409
|
||||||
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
||||||
WrennInternalError, # 500
|
WrennAgentError, # 502
|
||||||
WrennAgentError, # 502
|
WrennInternalError, # 500
|
||||||
WrennHostUnavailableError, # 503
|
WrennHostUnavailableError, # 503
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -629,7 +603,7 @@ with WrennClient(api_key="wrn_...") as client:
|
|||||||
|
|
||||||
# Snapshots
|
# Snapshots
|
||||||
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
||||||
templates = client.snapshots.list(type="custom") # optional type filter
|
templates = client.snapshots.list()
|
||||||
client.snapshots.delete("my-snap")
|
client.snapshots.delete("my-snap")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
249
api/openapi.yaml
249
api/openapi.yaml
@ -1421,19 +1421,10 @@ paths:
|
|||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
- sessionAuth: []
|
- sessionAuth: []
|
||||||
description: |
|
description: |
|
||||||
Snapshot a capsule, processed asynchronously. The call returns
|
Live snapshot: briefly pauses the capsule, writes its VM state +
|
||||||
immediately with the capsule in the `snapshotting` state, then it
|
memory + flattened rootfs to a new template directory, then resumes
|
||||||
returns to its original state on completion. The capsule must be
|
the capsule. The source capsule keeps running after the snapshot;
|
||||||
`running` or `paused`.
|
the resulting template can be used to create new capsules.
|
||||||
|
|
||||||
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.
|
||||||
@ -1444,14 +1435,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateSnapshotRequest"
|
$ref: "#/components/schemas/CreateSnapshotRequest"
|
||||||
responses:
|
responses:
|
||||||
"202":
|
"201":
|
||||||
description: Snapshot accepted; capsule is now snapshotting
|
description: Snapshot created
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Capsule"
|
$ref: "#/components/schemas/Template"
|
||||||
"409":
|
"409":
|
||||||
description: Name already exists, or capsule is not running or paused
|
description: Name already exists or capsule not running
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -2716,39 +2707,14 @@ paths:
|
|||||||
tags: [admin]
|
tags: [admin]
|
||||||
security:
|
security:
|
||||||
- sessionAuth: []
|
- sessionAuth: []
|
||||||
parameters:
|
|
||||||
- name: page
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
default: 1
|
|
||||||
description: Page number for pagination.
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Paginated teams list
|
description: Teams list
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: array
|
||||||
properties:
|
items: {type: object}
|
||||||
teams:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/AdminTeam"
|
|
||||||
total:
|
|
||||||
type: integer
|
|
||||||
page:
|
|
||||||
type: integer
|
|
||||||
per_page:
|
|
||||||
type: integer
|
|
||||||
total_pages:
|
|
||||||
type: integer
|
|
||||||
"401":
|
|
||||||
$ref: "#/components/responses/Unauthorized"
|
|
||||||
"403":
|
|
||||||
$ref: "#/components/responses/Forbidden"
|
|
||||||
|
|
||||||
/v1/admin/teams/{id}/byoc:
|
/v1/admin/teams/{id}/byoc:
|
||||||
put:
|
put:
|
||||||
@ -2768,20 +2734,12 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [enabled]
|
required: [byoc]
|
||||||
properties:
|
properties:
|
||||||
enabled:
|
byoc: {type: boolean}
|
||||||
type: boolean
|
|
||||||
description: true to enable BYOC, false to disable.
|
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"204":
|
||||||
description: Updated
|
description: Updated
|
||||||
"400":
|
|
||||||
$ref: "#/components/responses/BadRequest"
|
|
||||||
"401":
|
|
||||||
$ref: "#/components/responses/Unauthorized"
|
|
||||||
"403":
|
|
||||||
$ref: "#/components/responses/Forbidden"
|
|
||||||
|
|
||||||
/v1/admin/teams/{id}:
|
/v1/admin/teams/{id}:
|
||||||
delete:
|
delete:
|
||||||
@ -2798,38 +2756,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
"400":
|
|
||||||
$ref: "#/components/responses/BadRequest"
|
|
||||||
"401":
|
|
||||||
$ref: "#/components/responses/Unauthorized"
|
|
||||||
"403":
|
|
||||||
$ref: "#/components/responses/Forbidden"
|
|
||||||
"404":
|
|
||||||
$ref: "#/components/responses/NotFound"
|
|
||||||
|
|
||||||
/v1/admin/hosts:
|
|
||||||
get:
|
|
||||||
summary: List all hosts (admin)
|
|
||||||
operationId: adminListHosts
|
|
||||||
tags: [admin]
|
|
||||||
security:
|
|
||||||
- sessionAuth: []
|
|
||||||
description: |
|
|
||||||
Returns all hosts across all teams with per-host resource consumption.
|
|
||||||
Includes team name for hosts associated with a team.
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Hosts list
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/Host"
|
|
||||||
"401":
|
|
||||||
$ref: "#/components/responses/Unauthorized"
|
|
||||||
"403":
|
|
||||||
$ref: "#/components/responses/Forbidden"
|
|
||||||
|
|
||||||
/v1/admin/users:
|
/v1/admin/users:
|
||||||
get:
|
get:
|
||||||
@ -2887,7 +2813,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/AdminTemplate"
|
$ref: "#/components/schemas/Template"
|
||||||
|
|
||||||
/v1/admin/templates/{name}:
|
/v1/admin/templates/{name}:
|
||||||
delete:
|
delete:
|
||||||
@ -2973,26 +2899,6 @@ 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)
|
||||||
@ -3063,10 +2969,6 @@ 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:
|
||||||
@ -3075,22 +2977,21 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema: {type: string}
|
schema: {type: string}
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
|
required: [name]
|
||||||
properties:
|
properties:
|
||||||
name:
|
name: {type: string}
|
||||||
type: string
|
|
||||||
description: Optional; an auto-generated name is used when omitted.
|
|
||||||
responses:
|
responses:
|
||||||
"202":
|
"201":
|
||||||
description: Snapshot accepted; capsule is now snapshotting
|
description: Snapshot created
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Capsule"
|
$ref: "#/components/schemas/Template"
|
||||||
|
|
||||||
/v1/admin/capsules/{id}/exec:
|
/v1/admin/capsules/{id}/exec:
|
||||||
parameters:
|
parameters:
|
||||||
@ -3585,7 +3486,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
default: minimal-ubuntu
|
default: minimal
|
||||||
vcpus:
|
vcpus:
|
||||||
type: integer
|
type: integer
|
||||||
default: 1
|
default: 1
|
||||||
@ -3646,6 +3547,10 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
memory_mb_reserved:
|
memory_mb_reserved:
|
||||||
type: integer
|
type: integer
|
||||||
|
sampled_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
peaks:
|
peaks:
|
||||||
type: object
|
type: object
|
||||||
description: Maximum values over the last 30 days.
|
description: Maximum values over the last 30 days.
|
||||||
@ -3685,7 +3590,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error]
|
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
vcpus:
|
vcpus:
|
||||||
@ -3694,6 +3599,10 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
timeout_sec:
|
timeout_sec:
|
||||||
type: integer
|
type: integer
|
||||||
|
guest_ip:
|
||||||
|
type: string
|
||||||
|
host_ip:
|
||||||
|
type: string
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -3718,11 +3627,7 @@ components:
|
|||||||
agent_version, envd_version) when running.
|
agent_version, envd_version) when running.
|
||||||
disk_size_mb:
|
disk_size_mb:
|
||||||
type: integer
|
type: integer
|
||||||
description: Maximum disk capacity in MiB.
|
nullable: true
|
||||||
disk_used_mb:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
description: Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses.
|
|
||||||
|
|
||||||
CreateSnapshotRequest:
|
CreateSnapshotRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -3759,51 +3664,13 @@ 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-ubuntu` rootfs). False for team-owned
|
e.g. the built-in `minimal` 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]
|
||||||
@ -4074,25 +3941,6 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
team_name:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
description: Team name (included when listing hosts as an admin).
|
|
||||||
running_vcpus:
|
|
||||||
type: integer
|
|
||||||
description: Total vCPUs allocated to running capsules on this host.
|
|
||||||
running_memory_mb:
|
|
||||||
type: integer
|
|
||||||
description: Total memory in MB allocated to running capsules on this host.
|
|
||||||
running_disk_mb:
|
|
||||||
type: integer
|
|
||||||
description: Total disk in MB allocated to running capsules on this host.
|
|
||||||
paused_memory_mb:
|
|
||||||
type: integer
|
|
||||||
description: Total memory in MB allocated to paused capsules on this host.
|
|
||||||
paused_disk_mb:
|
|
||||||
type: integer
|
|
||||||
description: Total disk in MB allocated to paused capsules on this host.
|
|
||||||
|
|
||||||
RefreshHostTokenRequest:
|
RefreshHostTokenRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -4204,39 +4052,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/TeamMember"
|
$ref: "#/components/schemas/TeamMember"
|
||||||
|
|
||||||
AdminTeam:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
slug:
|
|
||||||
type: string
|
|
||||||
is_byoc:
|
|
||||||
type: boolean
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
deleted_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
member_count:
|
|
||||||
type: integer
|
|
||||||
owner_name:
|
|
||||||
type: string
|
|
||||||
owner_email:
|
|
||||||
type: string
|
|
||||||
active_sandbox_count:
|
|
||||||
type: integer
|
|
||||||
channel_count:
|
|
||||||
type: integer
|
|
||||||
running_vcpus:
|
|
||||||
type: integer
|
|
||||||
running_memory_mb:
|
|
||||||
type: integer
|
|
||||||
|
|
||||||
CapsuleMetrics:
|
CapsuleMetrics:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.2.0"
|
version = "0.1.4"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@ -164,17 +164,4 @@ def __getattr__(name: str) -> type:
|
|||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
return WrennHostHasCapsulesError
|
return WrennHostHasCapsulesError
|
||||||
if name in ("GitError", "GitCommandError", "GitAuthError"):
|
|
||||||
from wrenn._git.exceptions import (
|
|
||||||
GitAuthError as _GitAuthError,
|
|
||||||
GitCommandError as _GitCommandError,
|
|
||||||
GitError as _GitError,
|
|
||||||
)
|
|
||||||
|
|
||||||
_m: dict[str, type] = {
|
|
||||||
"GitError": _GitError,
|
|
||||||
"GitCommandError": _GitCommandError,
|
|
||||||
"GitAuthError": _GitAuthError,
|
|
||||||
}
|
|
||||||
return _m[name]
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@ -1,17 +1,65 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
|
APIKeyResponse,
|
||||||
Capsule,
|
Capsule,
|
||||||
|
CreateAPIKeyRequest,
|
||||||
|
CreateCapsuleRequest,
|
||||||
|
CreateHostRequest,
|
||||||
|
CreateHostResponse,
|
||||||
|
CreateSnapshotRequest,
|
||||||
|
Encoding,
|
||||||
|
Error,
|
||||||
|
Error1,
|
||||||
|
ExecRequest,
|
||||||
|
ExecResponse,
|
||||||
FileEntry,
|
FileEntry,
|
||||||
|
Host,
|
||||||
|
ListDirRequest,
|
||||||
ListDirResponse,
|
ListDirResponse,
|
||||||
|
LoginRequest,
|
||||||
|
MakeDirRequest,
|
||||||
MakeDirResponse,
|
MakeDirResponse,
|
||||||
|
ReadFileRequest,
|
||||||
|
RegisterHostRequest,
|
||||||
|
RegisterHostResponse,
|
||||||
|
RemoveRequest,
|
||||||
|
SignupRequest,
|
||||||
Status,
|
Status,
|
||||||
|
Status1,
|
||||||
Template,
|
Template,
|
||||||
|
Type,
|
||||||
|
Type1,
|
||||||
|
Type2,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Capsule",
|
"APIKeyResponse",
|
||||||
|
"CreateAPIKeyRequest",
|
||||||
|
"CreateHostRequest",
|
||||||
|
"CreateHostResponse",
|
||||||
|
"CreateCapsuleRequest",
|
||||||
|
"CreateSnapshotRequest",
|
||||||
|
"Encoding",
|
||||||
|
"Error",
|
||||||
|
"Error1",
|
||||||
|
"ExecRequest",
|
||||||
|
"ExecResponse",
|
||||||
"FileEntry",
|
"FileEntry",
|
||||||
|
"Host",
|
||||||
|
"ListDirRequest",
|
||||||
"ListDirResponse",
|
"ListDirResponse",
|
||||||
|
"LoginRequest",
|
||||||
|
"MakeDirRequest",
|
||||||
"MakeDirResponse",
|
"MakeDirResponse",
|
||||||
|
"ReadFileRequest",
|
||||||
|
"RegisterHostRequest",
|
||||||
|
"RegisterHostResponse",
|
||||||
|
"RemoveRequest",
|
||||||
|
"Capsule",
|
||||||
|
"SignupRequest",
|
||||||
"Status",
|
"Status",
|
||||||
|
"Status1",
|
||||||
"Template",
|
"Template",
|
||||||
|
"Type",
|
||||||
|
"Type1",
|
||||||
|
"Type2",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,20 +1,153 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-05-23T11:20:02+00:00
|
# timestamp: 2026-05-19T08:54:50+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, Field
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
from datetime import date as date_aliased
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class SignupRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: Annotated[str, Field(min_length=8)]
|
||||||
|
name: Annotated[str, Field(max_length=100)]
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignupResponse(BaseModel):
|
||||||
|
message: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Confirmation message instructing user to check email"),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Returned by login, activate, and switch-team. The actual auth credential
|
||||||
|
is the wrenn_sid cookie set on the response. The body carries identity
|
||||||
|
data the SPA needs to bootstrap.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id: str | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
is_admin: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateAPIKeyRequest(BaseModel):
|
||||||
|
name: str | None = "Unnamed API Key"
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
key_prefix: Annotated[
|
||||||
|
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
|
||||||
|
] = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
last_used: AwareDatetime | None = None
|
||||||
|
key: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Full plaintext key. Only returned on creation, never again."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCapsuleRequest(BaseModel):
|
||||||
|
template: str | None = "minimal"
|
||||||
|
vcpus: int | None = 1
|
||||||
|
memory_mb: int | None = 512
|
||||||
|
disk_size_mb: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
|
||||||
|
),
|
||||||
|
] = 5120
|
||||||
|
timeout_sec: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
|
||||||
|
ge=0,
|
||||||
|
),
|
||||||
|
] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Point(BaseModel):
|
||||||
|
date: date_aliased | None = None
|
||||||
|
cpu_minutes: float | None = None
|
||||||
|
ram_mb_minutes: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UsageResponse(BaseModel):
|
||||||
|
from_: Annotated[date_aliased | None, Field(alias="from")] = None
|
||||||
|
to: date_aliased | None = None
|
||||||
|
points: list[Point] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Range(StrEnum):
|
||||||
|
field_5m = "5m"
|
||||||
|
field_1h = "1h"
|
||||||
|
field_6h = "6h"
|
||||||
|
field_24h = "24h"
|
||||||
|
field_30d = "30d"
|
||||||
|
|
||||||
|
|
||||||
|
class Current(BaseModel):
|
||||||
|
running_count: int | None = None
|
||||||
|
vcpus_reserved: int | None = None
|
||||||
|
memory_mb_reserved: int | None = None
|
||||||
|
sampled_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Peaks(BaseModel):
|
||||||
|
"""
|
||||||
|
Maximum values over the last 30 days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
running_count: int | None = None
|
||||||
|
vcpus: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Series(BaseModel):
|
||||||
|
"""
|
||||||
|
Parallel arrays for chart rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
labels: list[AwareDatetime] | None = None
|
||||||
|
running: list[int] | None = None
|
||||||
|
vcpus: list[int] | None = None
|
||||||
|
memory_mb: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapsuleStats(BaseModel):
|
||||||
|
range: Range | None = None
|
||||||
|
current: Current | None = None
|
||||||
|
peaks: Annotated[
|
||||||
|
Peaks | None, Field(description="Maximum values over the last 30 days.")
|
||||||
|
] = None
|
||||||
|
series: Annotated[
|
||||||
|
Series | None, Field(description="Parallel arrays for chart rendering.")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class Status(StrEnum):
|
class Status(StrEnum):
|
||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
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"
|
||||||
@ -30,6 +163,8 @@ class Capsule(BaseModel):
|
|||||||
vcpus: int | None = None
|
vcpus: int | None = None
|
||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
timeout_sec: int | None = None
|
timeout_sec: int | None = None
|
||||||
|
guest_ip: str | None = None
|
||||||
|
host_ip: str | None = None
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
started_at: AwareDatetime | None = None
|
started_at: AwareDatetime | None = None
|
||||||
last_active_at: AwareDatetime | None = None
|
last_active_at: AwareDatetime | None = None
|
||||||
@ -40,14 +175,16 @@ class Capsule(BaseModel):
|
|||||||
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
|
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
disk_size_mb: Annotated[
|
disk_size_mb: int | None = None
|
||||||
int | None, Field(description="Maximum disk capacity in MiB.")
|
|
||||||
] = None
|
|
||||||
disk_used_mb: Annotated[
|
class CreateSnapshotRequest(BaseModel):
|
||||||
int | None,
|
sandbox_id: Annotated[
|
||||||
Field(
|
str, Field(description="ID of the running capsule to snapshot.")
|
||||||
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
|
]
|
||||||
),
|
name: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Name for the snapshot template. Auto-generated if omitted."),
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -66,19 +203,100 @@ 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-ubuntu` 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` 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 Type2(StrEnum):
|
class ExecRequest(BaseModel):
|
||||||
|
cmd: str
|
||||||
|
args: list[str] | None = None
|
||||||
|
timeout_sec: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(description="Timeout in seconds (foreground exec only, default 30)"),
|
||||||
|
] = 30
|
||||||
|
background: Annotated[
|
||||||
|
bool | None,
|
||||||
|
Field(
|
||||||
|
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
|
||||||
|
),
|
||||||
|
] = False
|
||||||
|
tag: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
envs: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Environment variables for the process (background exec only)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
cwd: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Working directory for the process (background exec only)"),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundExecResponse(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
pid: int | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessEntry(BaseModel):
|
||||||
|
pid: int | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
args: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessListResponse(BaseModel):
|
||||||
|
processes: list[ProcessEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(StrEnum):
|
||||||
|
"""
|
||||||
|
Output encoding. "base64" when stdout/stderr contain binary data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
utf_8 = "utf-8"
|
||||||
|
base64 = "base64"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecResponse(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
encoding: Annotated[
|
||||||
|
Encoding | None,
|
||||||
|
Field(
|
||||||
|
description='Output encoding. "base64" when stdout/stderr contain binary data.'
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReadFileRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Directory path inside the capsule")]
|
||||||
|
depth: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
|
||||||
|
),
|
||||||
|
] = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Type1(StrEnum):
|
||||||
file = "file"
|
file = "file"
|
||||||
directory = "directory"
|
directory = "directory"
|
||||||
symlink = "symlink"
|
symlink = "symlink"
|
||||||
@ -87,7 +305,7 @@ class Type2(StrEnum):
|
|||||||
class FileEntry(BaseModel):
|
class FileEntry(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
type: Type2 | None = None
|
type: Type1 | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
mode: int | None = None
|
mode: int | None = None
|
||||||
permissions: Annotated[
|
permissions: Annotated[
|
||||||
@ -101,9 +319,438 @@ class FileEntry(BaseModel):
|
|||||||
symlink_target: str | None = None
|
symlink_target: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MakeDirRequest(BaseModel):
|
||||||
|
path: Annotated[
|
||||||
|
str, Field(description="Directory path to create inside the capsule")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MakeDirResponse(BaseModel):
|
class MakeDirResponse(BaseModel):
|
||||||
entry: FileEntry | None = None
|
entry: FileEntry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
|
class Type2(StrEnum):
|
||||||
|
"""
|
||||||
|
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regular = "regular"
|
||||||
|
byoc = "byoc"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHostRequest(BaseModel):
|
||||||
|
type: Annotated[
|
||||||
|
Type2,
|
||||||
|
Field(
|
||||||
|
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
|
||||||
|
provider: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
|
||||||
|
] = None
|
||||||
|
availability_zone: Annotated[
|
||||||
|
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterHostRequest(BaseModel):
|
||||||
|
token: Annotated[
|
||||||
|
str, Field(description="One-time registration token from POST /v1/hosts.")
|
||||||
|
]
|
||||||
|
arch: Annotated[
|
||||||
|
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
|
||||||
|
] = None
|
||||||
|
cpu_cores: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
disk_gb: int | None = None
|
||||||
|
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
||||||
|
|
||||||
|
|
||||||
|
class Type3(StrEnum):
|
||||||
|
regular = "regular"
|
||||||
|
byoc = "byoc"
|
||||||
|
|
||||||
|
|
||||||
|
class Status1(StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
online = "online"
|
||||||
|
offline = "offline"
|
||||||
|
draining = "draining"
|
||||||
|
unreachable = "unreachable"
|
||||||
|
|
||||||
|
|
||||||
|
class Host(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: Type3 | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
availability_zone: str | None = None
|
||||||
|
arch: str | None = None
|
||||||
|
cpu_cores: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
disk_gb: int | None = None
|
||||||
|
address: str | None = None
|
||||||
|
status: Status1 | None = None
|
||||||
|
last_heartbeat_at: AwareDatetime | None = None
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
updated_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshHostTokenRequest(BaseModel):
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="Refresh token obtained from registration or a previous refresh."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshHostTokenResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
token: Annotated[
|
||||||
|
str | None, Field(description="New host JWT. Valid for 7 days.")
|
||||||
|
] = None
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="New refresh token. Valid for 60 days; old token is revoked."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostDeletePreview(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
sandbox_ids: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="IDs of capsules that would be destroyed on force-delete."),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error(BaseModel):
|
||||||
|
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
||||||
|
message: str | None = None
|
||||||
|
sandbox_ids: Annotated[
|
||||||
|
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostHasCapsulesError(BaseModel):
|
||||||
|
error: Error | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddTagRequest(BaseModel):
|
||||||
|
tag: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchResult(BaseModel):
|
||||||
|
user_id: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Team(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
slug: Annotated[
|
||||||
|
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
|
||||||
|
] = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Role(StrEnum):
|
||||||
|
owner = "owner"
|
||||||
|
admin = "admin"
|
||||||
|
member = "member"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamWithRole(Team):
|
||||||
|
role: Role | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMember(BaseModel):
|
||||||
|
user_id: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
role: Role | None = None
|
||||||
|
joined_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamDetail(BaseModel):
|
||||||
|
team: Team | None = None
|
||||||
|
members: list[TeamMember] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Range1(StrEnum):
|
||||||
|
field_5m = "5m"
|
||||||
|
field_10m = "10m"
|
||||||
|
field_1h = "1h"
|
||||||
|
field_2h = "2h"
|
||||||
|
field_6h = "6h"
|
||||||
|
field_12h = "12h"
|
||||||
|
field_24h = "24h"
|
||||||
|
|
||||||
|
|
||||||
|
class MetricPoint(BaseModel):
|
||||||
|
timestamp_unix: int | None = None
|
||||||
|
cpu_pct: Annotated[
|
||||||
|
float | None,
|
||||||
|
Field(
|
||||||
|
description="CPU utilization percentage (0-100), normalized to vCPU count"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
mem_bytes: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
disk_bytes: Annotated[
|
||||||
|
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(StrEnum):
|
||||||
|
discord = "discord"
|
||||||
|
slack = "slack"
|
||||||
|
teams = "teams"
|
||||||
|
googlechat = "googlechat"
|
||||||
|
telegram = "telegram"
|
||||||
|
matrix = "matrix"
|
||||||
|
webhook = "webhook"
|
||||||
|
|
||||||
|
|
||||||
|
class Event(StrEnum):
|
||||||
|
capsule_create = "capsule.create"
|
||||||
|
capsule_pause = "capsule.pause"
|
||||||
|
capsule_resume = "capsule.resume"
|
||||||
|
capsule_destroy = "capsule.destroy"
|
||||||
|
template_snapshot_create = "template.snapshot.create"
|
||||||
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
|
host_up = "host.up"
|
||||||
|
host_down = "host.down"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateChannelRequest(BaseModel):
|
||||||
|
name: Annotated[str, Field(description="Unique channel name within the team.")]
|
||||||
|
provider: Provider
|
||||||
|
config: Annotated[
|
||||||
|
dict[str, str],
|
||||||
|
Field(
|
||||||
|
description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
events: list[Event]
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelRequest(BaseModel):
|
||||||
|
provider: Provider
|
||||||
|
config: Annotated[
|
||||||
|
dict[str, str],
|
||||||
|
Field(
|
||||||
|
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RotateConfigRequest(BaseModel):
|
||||||
|
config: Annotated[
|
||||||
|
dict[str, str],
|
||||||
|
Field(
|
||||||
|
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChannelRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
events: list[Event]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
provider: Provider | None = None
|
||||||
|
events: list[str] | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
updated_at: AwareDatetime | None = None
|
||||||
|
secret: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Webhook secret. Only returned on creation, never again."),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
has_password: Annotated[
|
||||||
|
bool | None,
|
||||||
|
Field(
|
||||||
|
description="Whether the user has a password set (false for OAuth-only accounts)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
providers: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: Annotated[
|
||||||
|
str | None, Field(description="Required when changing an existing password")
|
||||||
|
] = None
|
||||||
|
new_password: Annotated[str, Field(min_length=8)]
|
||||||
|
confirm_password: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Required when adding a password to an OAuth-only account (must match new_password)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error2(BaseModel):
|
||||||
|
code: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error1(BaseModel):
|
||||||
|
error: Error2 | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActorType(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
host = "host"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Status2(StrEnum):
|
||||||
|
success = "success"
|
||||||
|
failure = "failure"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogEntry(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
actor_type: ActorType | None = None
|
||||||
|
actor_id: str | None = None
|
||||||
|
actor_name: str | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
resource_id: str | None = None
|
||||||
|
action: str | None = None
|
||||||
|
scope: str | None = None
|
||||||
|
status: Status2 | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Event2(StrEnum):
|
||||||
|
connected = "connected"
|
||||||
|
capsule_create = "capsule.create"
|
||||||
|
capsule_pause = "capsule.pause"
|
||||||
|
capsule_resume = "capsule.resume"
|
||||||
|
capsule_destroy = "capsule.destroy"
|
||||||
|
capsule_state_changed = "capsule.state.changed"
|
||||||
|
template_snapshot_create = "template.snapshot.create"
|
||||||
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
|
host_up = "host.up"
|
||||||
|
host_down = "host.down"
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""
|
||||||
|
Present for action events (capsule.* except state.changed,
|
||||||
|
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
|
||||||
|
and the connected sentinel.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
success = "success"
|
||||||
|
error = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Type4(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Actor(BaseModel):
|
||||||
|
type: Type4 | None = None
|
||||||
|
id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSEEvent(BaseModel):
|
||||||
|
"""
|
||||||
|
Wire format of one SSE message body. The event name (`event:` line) is
|
||||||
|
the `kind` and the JSON below is the `data:` line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
event: Event2 | None = None
|
||||||
|
outcome: Annotated[
|
||||||
|
Outcome | None,
|
||||||
|
Field(
|
||||||
|
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
resource: Resource | None = None
|
||||||
|
actor: Actor | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
error: Annotated[
|
||||||
|
str | None, Field(description="Failure reason; only set when outcome=error.")
|
||||||
|
] = None
|
||||||
|
sandbox: Annotated[
|
||||||
|
Capsule | None,
|
||||||
|
Field(description="Populated for capsule.* events; null if DB lookup failed."),
|
||||||
|
] = None
|
||||||
|
timestamp: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListDirResponse(BaseModel):
|
class ListDirResponse(BaseModel):
|
||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHostResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
registration_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="One-time registration token for the host agent. Expires in 1 hour."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterHostResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
|
||||||
|
] = None
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapsuleMetrics(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
range: Range1 | None = None
|
||||||
|
points: list[MetricPoint] | None = None
|
||||||
|
|||||||
@ -74,32 +74,32 @@ class TestFilesList:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "main.py",
|
"name": "main.py",
|
||||||
"path": "/home/wrenn-user/main.py",
|
"path": "/home/user/main.py",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"mode": 33188,
|
"mode": 33188,
|
||||||
"permissions": "-rw-r--r--",
|
"permissions": "-rw-r--r--",
|
||||||
"owner": "wrenn-user",
|
"owner": "root",
|
||||||
"group": "wrenn-user",
|
"group": "root",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "config",
|
"name": "config",
|
||||||
"path": "/home/wrenn-user/config",
|
"path": "/home/user/config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "wrenn-user",
|
"owner": "root",
|
||||||
"group": "wrenn-user",
|
"group": "root",
|
||||||
"modified_at": 1712899100,
|
"modified_at": 1712899100,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entries = cap.files.list("/home/wrenn-user")
|
entries = cap.files.list("/home/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/wrenn-user", depth=3)
|
cap.files.list("/home/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/wrenn-user/data",
|
"path": "/home/user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "wrenn-user",
|
"owner": "root",
|
||||||
"group": "wrenn-user",
|
"group": "root",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/wrenn-user/data")
|
entry = cap.files.make_dir("/home/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/wrenn-user/data",
|
"path": "/home/user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "wrenn-user",
|
"owner": "root",
|
||||||
"group": "wrenn-user",
|
"group": "root",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/wrenn-user/data")
|
entry = cap.files.make_dir("/home/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/wrenn-user/old_data")
|
cap.files.remove("/home/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/wrenn-user",
|
cwd="/home/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 /home/wrenn-user (default cwd) since the exec API
|
Initializes a repo at /root (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("/home/wrenn-user/.git")
|
assert self.capsule.files.exists("/root/.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("/home/wrenn-user/hello.txt", "hello git")
|
self.capsule.files.write("/root/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("/home/wrenn-user/dirty.txt", "uncommitted")
|
self.capsule.files.write("/root/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("/home/wrenn-user/dirty.txt")
|
self.capsule.files.remove("/root/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() == "/home/wrenn-user"
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
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() == "/home/wrenn-user"
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
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,29 +115,9 @@ 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] == "/home/wrenn-user"
|
assert lines[0] == "/root"
|
||||||
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
|
||||||
@ -163,7 +143,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(
|
||||||
"sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
|
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
@ -408,9 +388,7 @@ 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(
|
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
|
||||||
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def teardown_class(cls):
|
def teardown_class(cls):
|
||||||
@ -420,74 +398,66 @@ class TestGitClone:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_clone_created_repo(self):
|
def test_clone_created_repo(self):
|
||||||
assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
|
assert self.capsule.files.exists("/root/wrenn/.git")
|
||||||
|
|
||||||
def test_clone_checked_out_files(self):
|
def test_clone_checked_out_files(self):
|
||||||
entries = self.capsule.files.list("/home/wrenn-user/wrenn")
|
entries = self.capsule.files.list("/root/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="/home/wrenn-user/wrenn")
|
status = self.capsule.git.status(cwd="/root/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="/home/wrenn-user/wrenn")
|
branches = self.capsule.git.branches(cwd="/root/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="/home/wrenn-user/wrenn")
|
url = self.capsule.git.remote_get("origin", cwd="/root/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(
|
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
|
||||||
"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="/home/wrenn-user/wrenn", scope="local"
|
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local"
|
||||||
)
|
)
|
||||||
self.capsule.files.write(
|
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
|
||||||
f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
|
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
|
||||||
)
|
|
||||||
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
|
|
||||||
|
|
||||||
staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
staged = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
assert staged.has_staged
|
assert staged.has_staged
|
||||||
|
|
||||||
result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
|
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
after = self.capsule.git.status(cwd="/root/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="/home/wrenn-user/wrenn")
|
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
|
||||||
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
branches = self.capsule.git.branches(cwd="/root/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="/home/wrenn-user/wrenn")
|
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
|
||||||
|
|
||||||
def test_diff_via_commands(self):
|
def test_diff_via_commands(self):
|
||||||
self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
|
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
|
||||||
try:
|
try:
|
||||||
result = self.capsule.commands.run(
|
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
|
||||||
"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(
|
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
|
||||||
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitErrors:
|
class TestGitErrors:
|
||||||
@ -511,7 +481,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",
|
||||||
"/home/wrenn-user/missing",
|
"/root/missing",
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -523,11 +493,7 @@ class TestGitErrors:
|
|||||||
|
|
||||||
def test_clone_with_branch(self):
|
def test_clone_with_branch(self):
|
||||||
self.capsule.git.clone(
|
self.capsule.git.clone(
|
||||||
WRENN_REPO,
|
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
|
||||||
"/home/wrenn-user/wrenn-main",
|
|
||||||
branch="main",
|
|
||||||
depth=1,
|
|
||||||
timeout=300,
|
|
||||||
)
|
)
|
||||||
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
|
status = self.capsule.git.status(cwd="/root/wrenn-main")
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
|
|||||||
Reference in New Issue
Block a user