diff --git a/Makefile b/Makefile index a4a57ba..7720026 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,9 @@ generate: --use-schema-description \ --target-python-version 3.13 \ --use-annotated \ - --openapi-scopes schemas + --openapi-scopes schemas \ + --formatters ruff-format ruff-check \ + --input-file-type openapi lint: uv run ruff check src/ diff --git a/api/openapi.yaml b/api/openapi.yaml index b6bd643..031cefd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -699,11 +699,17 @@ paths: $ref: "#/components/schemas/ExecRequest" responses: "200": - description: Command output + description: Command output (foreground exec) content: application/json: schema: $ref: "#/components/schemas/ExecResponse" + "202": + description: Background process started + content: + application/json: + schema: + $ref: "#/components/schemas/BackgroundExecResponse" "404": description: Capsule not found content: @@ -717,6 +723,122 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/capsules/{id}/processes: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List running processes + operationId: listProcesses + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Returns all running processes inside the capsule, including background + processes and any processes started by templates or init scripts. + responses: + "200": + description: Process list + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessListResponse" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + delete: + summary: Kill a process + operationId: killProcess + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: signal + in: query + required: false + description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) + schema: + type: string + enum: [SIGKILL, SIGTERM] + default: SIGKILL + responses: + "204": + description: Process killed + "404": + description: Capsule or process not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}/stream: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + get: + summary: Stream process output via WebSocket + operationId: connectProcess + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection to stream stdout/stderr from a running + background process. The selector can be a numeric PID or a string tag. + + Server sends JSON messages: + - `{"type": "start", "pid": 42}` — connected to process + - `{"type": "stdout", "data": "..."}` — stdout output + - `{"type": "stderr", "data": "..."}` — stderr output + - `{"type": "exit", "exit_code": 0}` — process exited + - `{"type": "error", "data": "..."}` — error message + responses: + "101": + description: WebSocket upgrade + /v1/capsules/{id}/ping: parameters: - name: id @@ -2153,6 +2275,56 @@ components: timeout_sec: type: integer default: 30 + description: Timeout in seconds (foreground exec only, default 30) + background: + type: boolean + default: false + description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) + tag: + type: string + description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. + envs: + type: object + additionalProperties: + type: string + description: Environment variables for the process (background exec only) + cwd: + type: string + description: Working directory for the process (background exec only) + + BackgroundExecResponse: + type: object + properties: + sandbox_id: + type: string + cmd: + type: string + pid: + type: integer + tag: + type: string + + ProcessEntry: + type: object + properties: + pid: + type: integer + tag: + type: string + cmd: + type: string + args: + type: array + items: + type: string + + ProcessListResponse: + type: object + properties: + processes: + type: array + items: + $ref: "#/components/schemas/ProcessEntry" ExecResponse: type: object diff --git a/pyproject.toml b/pyproject.toml index d7dbaff..839941f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ - "datamodel-code-generator>=0.56.0", + "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 55a5742..4ebdc74 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,13 +1,11 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-12T20:56:29+00:00 +# timestamp: 2026-04-15T08:37:41+00:00 from __future__ import annotations - -from enum import StrEnum -from typing import Annotated - from pydantic import AwareDatetime, BaseModel, EmailStr, Field +from typing import Annotated +from enum import StrEnum class SignupRequest(BaseModel): @@ -22,7 +20,7 @@ class LoginRequest(BaseModel): class AuthResponse(BaseModel): - token: Annotated[str | None, Field(description='JWT token (valid for 6 hours)')] = ( + token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( None ) user_id: str | None = None @@ -32,7 +30,7 @@ class AuthResponse(BaseModel): class CreateAPIKeyRequest(BaseModel): - name: str | None = 'Unnamed API Key' + name: str | None = "Unnamed API Key" class APIKeyResponse(BaseModel): @@ -47,29 +45,29 @@ class APIKeyResponse(BaseModel): key: Annotated[ str | None, Field( - description='Full plaintext key. Only returned on creation, never again.' + description="Full plaintext key. Only returned on creation, never again." ), ] = None class CreateCapsuleRequest(BaseModel): - template: str | None = 'minimal' + template: str | None = "minimal" vcpus: int | None = 1 memory_mb: int | None = 512 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.\n' + 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.\n" ), ] = 0 class Range(StrEnum): - field_5m = '5m' - field_1h = '1h' - field_6h = '6h' - field_24h = '24h' - field_30d = '30d' + field_5m = "5m" + field_1h = "1h" + field_6h = "6h" + field_24h = "24h" + field_30d = "30d" class Current(BaseModel): @@ -104,22 +102,22 @@ class CapsuleStats(BaseModel): range: Range | None = None current: Current | None = None peaks: Annotated[ - Peaks | None, Field(description='Maximum values over the last 30 days.') + Peaks | None, Field(description="Maximum values over the last 30 days.") ] = None series: Annotated[ - Series | None, Field(description='Parallel arrays for chart rendering.') + Series | None, Field(description="Parallel arrays for chart rendering.") ] = None class Status(StrEnum): - pending = 'pending' - starting = 'starting' - running = 'running' - paused = 'paused' - hibernated = 'hibernated' - stopped = 'stopped' - missing = 'missing' - error = 'error' + pending = "pending" + starting = "starting" + running = "running" + paused = "paused" + hibernated = "hibernated" + stopped = "stopped" + missing = "missing" + error = "error" class Capsule(BaseModel): @@ -139,17 +137,17 @@ class Capsule(BaseModel): class CreateSnapshotRequest(BaseModel): sandbox_id: Annotated[ - str, Field(description='ID of the running capsule to snapshot.') + str, Field(description="ID of the running capsule to snapshot.") ] name: Annotated[ str | None, - Field(description='Name for the snapshot template. Auto-generated if omitted.'), + Field(description="Name for the snapshot template. Auto-generated if omitted."), ] = None class Type(StrEnum): - base = 'base' - snapshot = 'snapshot' + base = "base" + snapshot = "snapshot" class Template(BaseModel): @@ -164,7 +162,50 @@ class Template(BaseModel): class ExecRequest(BaseModel): cmd: str args: list[str] | None = None - timeout_sec: int | None = 30 + 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): @@ -172,8 +213,8 @@ class Encoding(StrEnum): Output encoding. "base64" when stdout/stderr contain binary data. """ - utf_8 = 'utf-8' - base64 = 'base64' + utf_8 = "utf-8" + base64 = "base64" class ExecResponse(BaseModel): @@ -192,23 +233,23 @@ class ExecResponse(BaseModel): class ReadFileRequest(BaseModel): - path: Annotated[str, Field(description='Absolute file path inside the capsule')] + path: Annotated[str, Field(description="Absolute file path inside the capsule")] class ListDirRequest(BaseModel): - path: Annotated[str, Field(description='Directory path inside the capsule')] + path: Annotated[str, Field(description="Directory path inside the capsule")] depth: Annotated[ int | None, Field( - description='Recursion depth (0 = non-recursive, 1 = immediate children)' + description="Recursion depth (0 = non-recursive, 1 = immediate children)" ), ] = 1 class Type1(StrEnum): - file = 'file' - directory = 'directory' - symlink = 'symlink' + file = "file" + directory = "directory" + symlink = "symlink" class FileEntry(BaseModel): @@ -223,14 +264,14 @@ class FileEntry(BaseModel): owner: str | None = None group: str | None = None modified_at: Annotated[ - int | None, Field(description='Unix timestamp (seconds)') + int | None, Field(description="Unix timestamp (seconds)") ] = None symlink_target: str | None = None class MakeDirRequest(BaseModel): path: Annotated[ - str, Field(description='Directory path to create inside the capsule') + str, Field(description="Directory path to create inside the capsule") ] @@ -239,7 +280,7 @@ class MakeDirResponse(BaseModel): 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): @@ -247,51 +288,51 @@ class Type2(StrEnum): Host type. Regular hosts are shared; BYOC hosts belong to a team. """ - regular = 'regular' - byoc = 'byoc' + regular = "regular" + byoc = "byoc" class CreateHostRequest(BaseModel): type: Annotated[ Type2, 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." ), ] - team_id: Annotated[str | None, Field(description='Required for BYOC hosts.')] = None + 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).'), + 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).') + 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.') + str, Field(description="One-time registration token from POST /v1/hosts.") ] arch: Annotated[ - str | None, Field(description='CPU architecture (e.g. x86_64, aarch64).') + 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).')] + address: Annotated[str, Field(description="Host agent address (ip:port).")] class Type3(StrEnum): - regular = 'regular' - byoc = 'byoc' + regular = "regular" + byoc = "byoc" class Status1(StrEnum): - pending = 'pending' - online = 'online' - offline = 'offline' - draining = 'draining' - unreachable = 'unreachable' + pending = "pending" + online = "online" + offline = "offline" + draining = "draining" + unreachable = "unreachable" class Host(BaseModel): @@ -316,7 +357,7 @@ class RefreshHostTokenRequest(BaseModel): refresh_token: Annotated[ str, Field( - description='Refresh token obtained from registration or a previous refresh.' + description="Refresh token obtained from registration or a previous refresh." ), ] @@ -324,12 +365,12 @@ class RefreshHostTokenRequest(BaseModel): class RefreshHostTokenResponse(BaseModel): host: Host | None = None token: Annotated[ - str | None, Field(description='New host JWT. Valid for 7 days.') + 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.' + description="New refresh token. Valid for 60 days; old token is revoked." ), ] = None @@ -338,16 +379,16 @@ class HostDeletePreview(BaseModel): host: Host | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description='IDs of capsulees that would be destroyed on force-delete.'), + Field(description="IDs of capsulees that would be destroyed on force-delete."), ] = None class Error(BaseModel): - code: Annotated[str | None, Field(examples=['host_has_sandboxes'])] = None + 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 capsulees blocking deletion.'), + Field(description="IDs of active capsulees blocking deletion."), ] = None @@ -368,15 +409,15 @@ 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)') + 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' + owner = "owner" + admin = "admin" + member = "member" class TeamWithRole(Team): @@ -396,13 +437,13 @@ class TeamDetail(BaseModel): class Range1(StrEnum): - field_5m = '5m' - field_10m = '10m' - field_1h = '1h' - field_2h = '2h' - field_6h = '6h' - field_12h = '12h' - field_24h = '24h' + field_5m = "5m" + field_10m = "10m" + field_1h = "1h" + field_2h = "2h" + field_6h = "6h" + field_12h = "12h" + field_24h = "24h" class MetricPoint(BaseModel): @@ -410,41 +451,41 @@ class MetricPoint(BaseModel): cpu_pct: Annotated[ float | None, Field( - description='CPU utilization percentage (0-100), normalized to vCPU count' + description="CPU utilization percentage (0-100), normalized to vCPU count" ), ] = None mem_bytes: Annotated[ int | None, - Field(description='Resident memory in bytes (VmRSS of Firecracker process)'), + Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), ] = None disk_bytes: Annotated[ - int | None, Field(description='Allocated disk bytes for the CoW sparse file') + 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' + discord = "discord" + slack = "slack" + teams = "teams" + googlechat = "googlechat" + telegram = "telegram" + matrix = "matrix" + webhook = "webhook" class Event(StrEnum): - capsule_created = 'capsule.created' - capsule_running = 'capsule.running' - capsule_paused = 'capsule.paused' - capsule_destroyed = 'capsule.destroyed' - template_snapshot_created = 'template.snapshot.created' - template_snapshot_deleted = 'template.snapshot.deleted' - host_up = 'host.up' - host_down = 'host.down' + capsule_created = "capsule.created" + capsule_running = "capsule.running" + capsule_paused = "capsule.paused" + capsule_destroyed = "capsule.destroyed" + template_snapshot_created = "template.snapshot.created" + template_snapshot_deleted = "template.snapshot.deleted" + host_up = "host.up" + host_down = "host.down" class CreateChannelRequest(BaseModel): - name: Annotated[str, Field(description='Unique channel name within the team.')] + name: Annotated[str, Field(description="Unique channel name within the team.")] provider: Provider config: Annotated[ dict[str, str], @@ -460,7 +501,7 @@ class TestChannelRequest(BaseModel): config: Annotated[ dict[str, str], Field( - description='Provider-specific configuration fields (same as CreateChannelRequest.config).' + description="Provider-specific configuration fields (same as CreateChannelRequest.config)." ), ] @@ -489,7 +530,7 @@ class ChannelResponse(BaseModel): updated_at: AwareDatetime | None = None secret: Annotated[ str | None, - Field(description='Webhook secret. Only returned on creation, never again.'), + Field(description="Webhook secret. Only returned on creation, never again."), ] = None @@ -511,7 +552,7 @@ class CreateHostResponse(BaseModel): registration_token: Annotated[ str | None, Field( - description='One-time registration token for the host agent. Expires in 1 hour.' + description="One-time registration token for the host agent. Expires in 1 hour." ), ] = None @@ -520,12 +561,12 @@ class RegisterHostResponse(BaseModel): host: Host | None = None token: Annotated[ str | None, - Field(description='Host JWT for X-Host-Token header. Valid for 7 days.'), + 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.' + description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." ), ] = None diff --git a/uv.lock b/uv.lock index 22123d3..985de91 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -112,6 +112,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, ] +[package.optional-dependencies] +ruff = [ + { name = "ruff" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -684,7 +689,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "datamodel-code-generator" }, + { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -702,7 +707,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "datamodel-code-generator", specifier = ">=0.56.0" }, + { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "mypy", specifier = ">=1.20.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },