Compare commits
3 Commits
2d658f1500
...
87c808ffa9
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c808ffa9 | |||
| 61637694cb | |||
| 766bf7fccf |
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wrenn is an open-source, self-hosted dev environment platform. Users spin up isolated sandboxes (Cloud Hypervisor microVMs), run code inside them, and get output back via SDKs. Fast boot, persistent state, and a single agent binary on each host you own.
|
||||
Wrenn is the runtime where AI engineers live — an open-source platform for running AI coding agents. Each project gets a persistent, isolated workspace (Cloud Hypervisor microVM) where agents edit code, run tests, debug, and ship continuously, with humans supervising via SDKs and chat surfaces. Fast boot, persistent state, available hosted or fully self-hosted with a single agent binary on each host you own. (The underlying primitive is still an isolated microVM — "sandbox" in the API/backend, "capsule" in the dashboard.)
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Wrenn
|
||||
|
||||
Secure infrastructure for AI
|
||||
Runtime where AI engineers live
|
||||
|
||||
Wrenn is an open-source self-hosted dev environment platform. Each capsule is a fully isolated virtual machine — booted in seconds, persistent across sessions. Run the control plane anywhere, deploy a single agent binary on each compute host.
|
||||
Wrenn is an open-source platform for running AI coding agents. Each project gets a persistent, isolated microVM workspace — booted in seconds, stateful across sessions — where agents edit code, run tests, debug, and ship continuously while humans supervise via SDKs and chat surfaces. Available hosted or fully self-hosted: run the control plane anywhere, deploy a single agent binary on each compute host you own.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@ -205,7 +205,7 @@
|
||||
class="relative z-10 mt-10 font-mono text-ui uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||
style="animation: fadeUp 0.35s ease 0.2s both"
|
||||
>
|
||||
Isolated VMs. Milliseconds to live.
|
||||
Runtime where AI engineers live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -24,10 +24,11 @@ func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sand
|
||||
}
|
||||
|
||||
type createSandboxRequest struct {
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type sandboxResponse struct {
|
||||
@ -99,6 +100,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
VCPUs: req.VCPUs,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, req.Template, err)
|
||||
if err != nil {
|
||||
|
||||
@ -119,6 +119,8 @@ func serviceErrToHTTP(err error) (int, string, string) {
|
||||
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
|
||||
strings.Contains(msg, "no host has sufficient resources"):
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case strings.HasPrefix(msg, "invalid metadata: "):
|
||||
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
|
||||
case strings.Contains(msg, "invalid"):
|
||||
return http.StatusBadRequest, "invalid_request", "invalid request"
|
||||
default:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Wrenn API
|
||||
description: AI agent execution platform API.
|
||||
version: "0.2.0"
|
||||
description: API for Wrenn — the runtime where AI coding agents live, work, and ship.
|
||||
version: "0.2.2"
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
@ -169,6 +169,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Session revoked
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
@ -202,6 +204,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Not a member of this team
|
||||
content:
|
||||
@ -239,6 +243,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: Account not activated or disabled
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/auth/oauth/{provider}:
|
||||
parameters:
|
||||
@ -426,7 +436,7 @@ paths:
|
||||
operationId: requestPasswordReset
|
||||
tags: [account]
|
||||
description: |
|
||||
Sends a password reset link to the given email. Always returns 200
|
||||
Sends a password reset link to the given email. Always returns 204
|
||||
regardless of whether the email exists, to prevent account enumeration.
|
||||
The reset token expires in 15 minutes.
|
||||
requestBody:
|
||||
@ -610,6 +620,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: API key deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/v1/users/search:
|
||||
get:
|
||||
@ -714,6 +726,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TeamDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: JWT team does not match requested team
|
||||
content:
|
||||
@ -772,6 +786,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Team deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Caller is not the owner
|
||||
content:
|
||||
@ -802,6 +818,14 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TeamMember"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: JWT team does not match requested team
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
post:
|
||||
summary: Add a member by email
|
||||
@ -884,6 +908,12 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Role updated
|
||||
"400":
|
||||
description: Invalid team/user ID or invalid role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: Insufficient role or attempt to modify owner
|
||||
content:
|
||||
@ -907,6 +937,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Member removed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Insufficient role or attempt to remove owner
|
||||
content:
|
||||
@ -938,6 +970,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Left the team
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Owner cannot leave
|
||||
content:
|
||||
@ -966,12 +1000,20 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"502":
|
||||
description: Host agent error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
get:
|
||||
summary: List capsules for your team
|
||||
@ -1072,6 +1114,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1089,6 +1133,14 @@ paths:
|
||||
responses:
|
||||
"202":
|
||||
description: Capsule destruction initiated
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/exec:
|
||||
parameters:
|
||||
@ -1124,6 +1176,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BackgroundExecResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1136,6 +1190,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/processes:
|
||||
parameters:
|
||||
@ -1162,6 +1218,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProcessListResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1174,6 +1232,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/processes/{selector}:
|
||||
parameters:
|
||||
@ -1208,6 +1268,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Process killed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule or process not found
|
||||
content:
|
||||
@ -1220,6 +1282,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/processes/{selector}/stream:
|
||||
parameters:
|
||||
@ -1278,6 +1342,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Ping acknowledged, inactivity timer reset
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1344,6 +1410,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/pause:
|
||||
parameters:
|
||||
@ -1371,12 +1439,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: Capsule not running
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/resume:
|
||||
parameters:
|
||||
@ -1405,12 +1479,18 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: Capsule not paused
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/snapshots:
|
||||
post:
|
||||
@ -1450,6 +1530,10 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: Name already exists, or capsule is not running or paused
|
||||
content:
|
||||
@ -1501,12 +1585,26 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Snapshot deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Cannot delete a platform-owned / system base template
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: Template not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Delete failed (e.g. owning host offline)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/capsules/{id}/files/write:
|
||||
parameters:
|
||||
@ -1541,6 +1639,10 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: File uploaded
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: Capsule not running
|
||||
content:
|
||||
@ -1553,6 +1655,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/files/read:
|
||||
parameters:
|
||||
@ -1583,12 +1687,22 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule or file not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Capsule not running
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/files/list:
|
||||
parameters:
|
||||
@ -1618,6 +1732,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListDirResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1630,6 +1746,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/files/mkdir:
|
||||
parameters:
|
||||
@ -1659,6 +1777,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MakeDirResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1673,6 +1793,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/files/remove:
|
||||
parameters:
|
||||
@ -1698,6 +1820,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: File or directory removed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1710,6 +1834,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/exec/stream:
|
||||
parameters:
|
||||
@ -1749,9 +1875,15 @@ paths:
|
||||
```
|
||||
|
||||
The connection closes automatically after the process exits.
|
||||
|
||||
Note: capsule-not-found and not-running conditions encountered after the
|
||||
WebSocket upgrade are delivered as in-band `{"type": "error"}` frames
|
||||
rather than HTTP status codes.
|
||||
responses:
|
||||
"101":
|
||||
description: WebSocket upgrade
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1830,6 +1962,8 @@ paths:
|
||||
responses:
|
||||
"101":
|
||||
description: WebSocket upgrade
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1880,6 +2014,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: File uploaded
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule not found
|
||||
content:
|
||||
@ -1892,6 +2028,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"502":
|
||||
description: Host agent error while streaming the upload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/capsules/{id}/files/stream/read:
|
||||
parameters:
|
||||
@ -1925,6 +2069,8 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Capsule or file not found
|
||||
content:
|
||||
@ -1937,6 +2083,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/hosts:
|
||||
post:
|
||||
@ -2014,6 +2162,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Host"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
@ -2041,6 +2191,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Host deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
@ -2079,6 +2231,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateHostResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
@ -2148,6 +2302,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Heartbeat recorded
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
description: Invalid or missing host token
|
||||
content:
|
||||
@ -2265,6 +2421,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HostDeletePreview"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
@ -2301,6 +2459,8 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
post:
|
||||
summary: Add a tag to a host
|
||||
@ -2317,6 +2477,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Tag added
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
@ -2346,6 +2508,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Tag removed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
@ -2441,6 +2605,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Channel not found
|
||||
content:
|
||||
@ -2483,6 +2649,14 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Channel deleted
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Channel not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/channels/{id}/config:
|
||||
parameters:
|
||||
@ -2643,19 +2817,17 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditLogEntry"
|
||||
next_cursor:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
before:
|
||||
type: string
|
||||
format: date-time
|
||||
before_id:
|
||||
type: string
|
||||
next_before:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Cursor for the next page (pass as `before`). Present only when the page is non-empty.
|
||||
next_before_id:
|
||||
type: string
|
||||
description: Tiebreaker cursor for the next page (pass as `before_id`). Present only when the page is non-empty.
|
||||
|
||||
/v1/admin/events/stream:
|
||||
get:
|
||||
@ -2704,10 +2876,17 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditLogEntry"
|
||||
next_before:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Cursor for the next page (pass as `before`). Present only when the page is non-empty.
|
||||
next_before_id:
|
||||
type: string
|
||||
description: Tiebreaker cursor for the next page (pass as `before_id`). Present only when the page is non-empty.
|
||||
|
||||
/v1/admin/teams:
|
||||
get:
|
||||
@ -2918,8 +3097,8 @@ paths:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
responses:
|
||||
"202":
|
||||
description: Build queued
|
||||
"201":
|
||||
description: Build created
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
@ -3007,8 +3186,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateCapsuleRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Capsule created
|
||||
"202":
|
||||
description: Capsule creation accepted; provisioning asynchronously
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -3048,6 +3227,10 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
delete:
|
||||
summary: Destroy capsule (admin)
|
||||
operationId: adminDestroyCapsule
|
||||
@ -3055,8 +3238,16 @@ paths:
|
||||
security:
|
||||
- sessionAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Destroyed
|
||||
"202":
|
||||
description: Destroy accepted; teardown asynchronously
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/snapshot:
|
||||
post:
|
||||
@ -3091,6 +3282,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Capsule"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
|
||||
/v1/admin/capsules/{id}/exec:
|
||||
parameters:
|
||||
@ -3123,10 +3320,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BackgroundExecResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/metrics:
|
||||
parameters:
|
||||
@ -3155,8 +3356,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CapsuleMetrics"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/processes:
|
||||
parameters:
|
||||
@ -3177,10 +3382,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProcessListResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/processes/{selector}:
|
||||
parameters:
|
||||
@ -3210,10 +3419,14 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Process killed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/files/write:
|
||||
parameters:
|
||||
@ -3240,8 +3453,20 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: File uploaded
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"413":
|
||||
description: File too large
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/files/read:
|
||||
parameters:
|
||||
@ -3269,8 +3494,14 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/files/list:
|
||||
parameters:
|
||||
@ -3297,10 +3528,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListDirResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/files/mkdir:
|
||||
parameters:
|
||||
@ -3327,10 +3562,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MakeDirResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/files/remove:
|
||||
parameters:
|
||||
@ -3353,10 +3592,14 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: File or directory removed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/FailedPrecondition"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailable"
|
||||
|
||||
/v1/admin/capsules/{id}/exec/stream:
|
||||
parameters:
|
||||
@ -3466,6 +3709,13 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
ServiceUnavailable:
|
||||
description: The host serving this capsule is unreachable (host_unavailable)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
@ -3568,6 +3818,12 @@ components:
|
||||
key_prefix:
|
||||
type: string
|
||||
description: Display prefix (e.g. "wrn_ab12cd34...")
|
||||
created_by:
|
||||
type: string
|
||||
description: ID of the user who created the key
|
||||
creator_email:
|
||||
type: string
|
||||
description: Email of the creating user. Omitted if unavailable.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@ -3601,6 +3857,16 @@ components:
|
||||
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).
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
nullable: true
|
||||
description: >
|
||||
Optional user-supplied key/value labels attached to the capsule.
|
||||
Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values
|
||||
are at most 64 characters. Reserved system keys (kernel_version,
|
||||
vmm_version, agent_version, envd_version) are rejected. Metadata is
|
||||
set at create-time only.
|
||||
|
||||
UsageResponse:
|
||||
type: object
|
||||
@ -3706,9 +3972,10 @@ components:
|
||||
additionalProperties: {type: string}
|
||||
nullable: true
|
||||
description: |
|
||||
Free-form key/value labels attached at create-time. Also carries
|
||||
agent-side version info (kernel_version, vmm_version,
|
||||
agent_version, envd_version) when running.
|
||||
User-supplied key/value labels attached at create-time. Once the
|
||||
capsule is running this also carries agent-side system version info
|
||||
(kernel_version, vmm_version, agent_version, envd_version); those
|
||||
reserved keys are owned by the system and cannot be set by users.
|
||||
disk_size_mb:
|
||||
type: integer
|
||||
description: Maximum disk capacity in MiB.
|
||||
@ -3822,10 +4089,10 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Environment variables for the process (background exec only)
|
||||
description: Environment variables for the process (applies to both foreground and background exec)
|
||||
cwd:
|
||||
type: string
|
||||
description: Working directory for the process (background exec only)
|
||||
description: Working directory for the process (applies to both foreground and background exec)
|
||||
|
||||
BackgroundExecResponse:
|
||||
type: object
|
||||
@ -4019,6 +4286,15 @@ components:
|
||||
refresh_token:
|
||||
type: string
|
||||
description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.
|
||||
cert_pem:
|
||||
type: string
|
||||
description: mTLS client certificate (PEM). Only returned when the host enrolls into mTLS.
|
||||
key_pem:
|
||||
type: string
|
||||
description: mTLS client private key (PEM). Only returned when the host enrolls into mTLS.
|
||||
ca_cert_pem:
|
||||
type: string
|
||||
description: CA certificate (PEM) to verify the control plane. Only returned when the host enrolls into mTLS.
|
||||
|
||||
Host:
|
||||
type: object
|
||||
@ -4106,6 +4382,15 @@ components:
|
||||
refresh_token:
|
||||
type: string
|
||||
description: New refresh token. Valid for 60 days; old token is revoked.
|
||||
cert_pem:
|
||||
type: string
|
||||
description: mTLS client certificate (PEM). Only returned when the host enrolls into mTLS.
|
||||
key_pem:
|
||||
type: string
|
||||
description: mTLS client private key (PEM). Only returned when the host enrolls into mTLS.
|
||||
ca_cert_pem:
|
||||
type: string
|
||||
description: CA certificate (PEM) to verify the control plane. Only returned when the host enrolls into mTLS.
|
||||
|
||||
HostDeletePreview:
|
||||
type: object
|
||||
@ -4126,7 +4411,7 @@ components:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: host_has_sandboxes
|
||||
example: has_active_sandboxes
|
||||
message:
|
||||
type: string
|
||||
sandbox_ids:
|
||||
@ -4160,6 +4445,9 @@ components:
|
||||
slug:
|
||||
type: string
|
||||
description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)
|
||||
is_byoc:
|
||||
type: boolean
|
||||
description: Whether the team runs on its own bring-your-own-cloud hosts
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@ -4178,6 +4466,8 @@ components:
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
role:
|
||||
@ -4371,11 +4661,22 @@ components:
|
||||
MeResponse:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
team_id:
|
||||
type: string
|
||||
description: The user's currently active team
|
||||
name:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
role:
|
||||
type: string
|
||||
description: The user's role within the active team
|
||||
is_admin:
|
||||
type: boolean
|
||||
description: Whether the user is a platform administrator
|
||||
has_password:
|
||||
type: boolean
|
||||
description: Whether the user has a password set (false for OAuth-only accounts)
|
||||
|
||||
@ -49,6 +49,9 @@ type SandboxCreateParams struct {
|
||||
VCPUs int32
|
||||
MemoryMB int32
|
||||
TimeoutSec int32
|
||||
// Metadata holds user-supplied key/value labels attached at create-time.
|
||||
// Reserved system keys (kernel_version, etc.) are rejected by validation.
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// MinTimeoutSec mirrors internal/sandbox.MinTimeoutSec. Sub-minute TTLs race
|
||||
@ -125,6 +128,9 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
if err := validate.SafeName(p.Template); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
|
||||
}
|
||||
if err := validate.Metadata(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
|
||||
}
|
||||
if p.VCPUs <= 0 {
|
||||
p.VCPUs = 1
|
||||
}
|
||||
@ -175,6 +181,13 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
sandboxIDStr := id.FormatSandboxID(sandboxID)
|
||||
hostIDStr := id.FormatHostID(host.ID)
|
||||
|
||||
metaJSON := []byte("{}")
|
||||
if len(p.Metadata) > 0 {
|
||||
if metaJSON, err = json.Marshal(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sb, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
|
||||
ID: sandboxID,
|
||||
TeamID: p.TeamID,
|
||||
@ -187,7 +200,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
DiskSizeMb: 0,
|
||||
TemplateID: templateID,
|
||||
TemplateTeamID: templateTeamID,
|
||||
Metadata: []byte("{}"),
|
||||
Metadata: metaJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
|
||||
@ -258,14 +271,7 @@ func (s *SandboxService) createInBackground(
|
||||
slog.Warn("failed to update sandbox running after create", "id", sandboxIDStr, "error", dbErr)
|
||||
}
|
||||
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, p.Metadata, resp.Msg.Metadata)
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
Event: "sandbox.started", SandboxID: sandboxIDStr, TeamID: teamIDStr, HostID: hostIDStr,
|
||||
@ -274,6 +280,33 @@ func (s *SandboxService) createInBackground(
|
||||
})
|
||||
}
|
||||
|
||||
// persistSystemMetadata merges agent-provided system metadata over the given
|
||||
// user labels and writes the result to the sandbox row. System keys
|
||||
// (kernel_version, etc.) are applied last so they always win — users can never
|
||||
// override protected fields even if create-time validation were bypassed.
|
||||
// userMeta may be nil. No-op when the agent returned no system metadata.
|
||||
func (s *SandboxService) persistSystemMetadata(
|
||||
ctx context.Context, sandboxID pgtype.UUID, sandboxIDStr string,
|
||||
userMeta, systemMeta map[string]string,
|
||||
) {
|
||||
if len(systemMeta) == 0 {
|
||||
return
|
||||
}
|
||||
merged := make(map[string]string, len(userMeta)+len(systemMeta))
|
||||
for k, v := range userMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range systemMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
metaJSON, _ := json.Marshal(merged)
|
||||
if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List returns active sandboxes (excludes stopped/error) belonging to the given team.
|
||||
func (s *SandboxService) List(ctx context.Context, teamID pgtype.UUID) ([]db.Sandbox, error) {
|
||||
return s.DB.ListSandboxesByTeam(ctx, teamID)
|
||||
@ -447,13 +480,14 @@ func (s *SandboxService) resumeInBackground(
|
||||
slog.Warn("failed to update sandbox to running after resume", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata after resume", "id", sandboxIDStr, "error", err)
|
||||
// Refresh system metadata but preserve the user's labels — read the current
|
||||
// row and merge agent keys over it rather than overwriting wholesale.
|
||||
if len(resp.Msg.Metadata) > 0 {
|
||||
var existing map[string]string
|
||||
if cur, err := s.DB.GetSandbox(bgCtx, sandboxID); err == nil && len(cur.Metadata) > 0 {
|
||||
_ = json.Unmarshal(cur.Metadata, &existing)
|
||||
}
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, existing, resp.Msg.Metadata)
|
||||
}
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
|
||||
53
pkg/validate/metadata.go
Normal file
53
pkg/validate/metadata.go
Normal file
@ -0,0 +1,53 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// metadataKeyRe matches user metadata keys: alphanumeric start, then
|
||||
// alphanumeric, dash, underscore, or dot. Max 64 characters. Mirrors the
|
||||
// SafeName allowlist so keys stay predictable across the API and UI.
|
||||
var metadataKeyRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
const (
|
||||
// MaxMetadataKeys caps how many user labels a sandbox may carry.
|
||||
MaxMetadataKeys = 20
|
||||
// MaxMetadataValueLen caps the length (in characters) of a label value.
|
||||
MaxMetadataValueLen = 64
|
||||
)
|
||||
|
||||
// reservedMetadataKeys are written by the host agent after a VM boots and must
|
||||
// not be set by users — they carry the immutable system facts about the VM.
|
||||
// Keep in sync with (*sandbox.Manager).buildMetadata: if a key is added there
|
||||
// but not here, a user could set it at create-time and watch it silently get
|
||||
// overwritten by the agent on boot. (validate cannot import internal/sandbox,
|
||||
// hence the duplication — same pattern as service.MinTimeoutSec.)
|
||||
var reservedMetadataKeys = map[string]struct{}{
|
||||
"kernel_version": {},
|
||||
"vmm_version": {},
|
||||
"agent_version": {},
|
||||
"envd_version": {},
|
||||
}
|
||||
|
||||
// Metadata validates a user-supplied sandbox metadata map. It rejects reserved
|
||||
// system keys, enforces a key count limit, and constrains key/value shape so
|
||||
// the data stays safe to render and store. A nil/empty map is valid.
|
||||
func Metadata(meta map[string]string) error {
|
||||
if len(meta) > MaxMetadataKeys {
|
||||
return fmt.Errorf("too many metadata keys: %d (max %d)", len(meta), MaxMetadataKeys)
|
||||
}
|
||||
for k, v := range meta {
|
||||
if _, reserved := reservedMetadataKeys[k]; reserved {
|
||||
return fmt.Errorf("metadata key %q is reserved for system use", k)
|
||||
}
|
||||
if !metadataKeyRe.MatchString(k) {
|
||||
return fmt.Errorf("metadata key %q is invalid (max 64 chars, must match %s)", k, metadataKeyRe.String())
|
||||
}
|
||||
if utf8.RuneCountInString(v) > MaxMetadataValueLen {
|
||||
return fmt.Errorf("metadata value for key %q is too long (max %d chars)", k, MaxMetadataValueLen)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
pkg/validate/metadata_test.go
Normal file
48
pkg/validate/metadata_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]string{}, false},
|
||||
{"simple", map[string]string{"env": "prod", "owner": "alice"}, false},
|
||||
{"dotted-key", map[string]string{"team.name": "infra"}, false},
|
||||
{"empty-value", map[string]string{"flag": ""}, false},
|
||||
{"max-keys", makeKeys(MaxMetadataKeys), false},
|
||||
|
||||
{"reserved-kernel", map[string]string{"kernel_version": "6.1.0"}, true},
|
||||
{"reserved-vmm", map[string]string{"vmm_version": "x"}, true},
|
||||
{"reserved-agent", map[string]string{"agent_version": "x"}, true},
|
||||
{"reserved-envd", map[string]string{"envd_version": "x"}, true},
|
||||
{"too-many-keys", makeKeys(MaxMetadataKeys + 1), true},
|
||||
{"bad-key-leading-dot", map[string]string{".hidden": "v"}, true},
|
||||
{"bad-key-space", map[string]string{"my key": "v"}, true},
|
||||
{"key-too-long", map[string]string{strings.Repeat("a", 65): "v"}, true},
|
||||
{"value-too-long", map[string]string{"k": strings.Repeat("a", MaxMetadataValueLen+1)}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Metadata(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Metadata(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeKeys(n int) map[string]string {
|
||||
m := make(map[string]string, n)
|
||||
for i := range n {
|
||||
m[fmt.Sprintf("key%d", i)] = "v"
|
||||
}
|
||||
return m
|
||||
}
|
||||
Reference in New Issue
Block a user