Observability API (PAT v1)

PAT-authenticated HTTP surface consumed by the WebUI and any third-party observability tooling.

The controller ships two HTTP listeners — a deliberate separation between the orchestration plane and the observability plane.

PlaneDefault bindAuthRoutesConsumer
Orchestration127.0.0.1:8686none (loopback trust)/apply, /diff, /pods, /pats, …CLI over SSH tunnel
Observability0.0.0.0:8687PAT (Bearer token)/api/pat/v1/*WebUI, third-party tooling

The PAT plane is the only network-exposable surface. The orchestration plane is loopback-only on a fresh install; operators reach it via the same SSH tunnel voodu (the CLI) already uses for voodu apply.

URL versioning

All PAT routes live under /api/pat/v1/. The v1 prefix is part of the contract — a future shape change ships as v2 without breaking existing WebUI deployments. Clients should treat v1 as a fixed wire format for the lifetime of the WebUI's v0.* series.

Authentication

Every request must include:

Authorization: Bearer pat_<28-char base64url>
  • Token format: pat_ prefix + 6-char ID + 22-char secret (base64 URL-safe alphabet A-Z a-z 0-9 - _, ~132 bits entropy in the secret). Same shape family as JWT tokens — URL-safe, no padding, copy-paste safe.
  • The token is case-sensitive — pat_aB and pat_AB are distinct credentials.
  • Scheme name on the Authorization header is case-insensitive (bearer, BEARER all accepted).
  • Tokens are minted via the orchestration plane (vd pat create) and shown exactly once — operators paste them into the WebUI's "Add island" form.
  • The controller stores sha256(token) hex, compared in constant time.

Scopes

ScopeGrants
readGET endpoints (/stats, /system, /pods, /pods/{name}, /pods/{name}/logs, /logs, /metrics, /activity, /plugins)
config:readGET /config, values masked by default
configPOST /config and DELETE /config
actions/pods/{name}/restart, /images/prune, plugin install and removal, PAT list and revoke
deploythe /deploy/* subtree
manifeststhe resource routes: apply, delete, restart, rollback, stop and start, and the manifest reads. See Resources.

A PAT can carry several scopes.

Resource scopes

A PAT can also be limited to some resource scopes, the first label of a manifest (statefulset "cloud" "paris-roma" is in scope cloud):

vd pat create --name anway-cloud --scope manifests,config,read --resource-scopes cloud

The resource, config and pod action routes then refuse any other scope with 403. Without --resource-scopes, a PAT reaches every scope, as before. The read routes (/pods, /logs, /stats, /metrics) are not filtered yet: give a scoped token read only when it may see the whole host. Read-only PATs requested by an actions endpoint receive 403 Forbidden. Operators typically mint a single read,actions PAT per WebUI island; a read-only PAT is appropriate for view-only operator roles or external dashboards.

Failure modes

StatusMeaning
401 UnauthorizedMissing / malformed Bearer header, unknown PAT ID, or hash mismatch. Body is {"status":"error","error":"authentication required"} — generic to avoid information disclosure (which PAT IDs exist).
403 ForbiddenValid PAT but insufficient scope for the endpoint. Body specifies the missing scope: insufficient scope (requires "actions").
429 Too Many RequestsAction-endpoint rate limit exceeded for this PAT. Read endpoints are not rate-limited.

Rate limiting

Action endpoints (currently POST /api/pat/v1/pods/{name}/restart) are rate-limited per PAT — not per IP, not globally. Defaults:

  • Steady rate: 10 requests / minute (≈ 0.167 RPS).
  • Burst: 3 requests instantly available.

Configurable via the controller's --pat-action-rate (RPS) and --pat-action-burst flags. A misbehaving WebUI cannot starve other PATs because each token has its own bucket. The bucket map is LRU-capped at 1000 entries — operators with more than 1000 concurrent active PATs will see oldest-evicted limiters reset (no practical concern for the WebUI deployment topology).

Endpoints

All responses use the standard envelope:

{ "status": "ok" | "error", "data": { ... }, "error": "..." }

GET /api/pat/v1/stats

Host + per-pod resource usage. Identical shape to the orchestration plane's GET /stats.

{
  "status": "ok",
  "data": {
    "host":  { "cpu_percent": 12.4, "mem_used_bytes": ..., "mem_total_bytes": ... },
    "pods":  [ { "name": "myapp-web.a3f9", "cpu_percent": 1.2, "mem_used_bytes": ... }, ... ]
  }
}

GET /api/pat/v1/pods

All voodu-managed containers on the host.

{
  "status": "ok",
  "data": {
    "pods": [
      {
        "name":          "myapp-web.a3f9",
        "kind":          "deployment",
        "scope":         "myapp",
        "resource_name": "web",
        "replica_id":    "a3f9",
        "image":         "myapp:latest",
        "status":        "Up 2 hours",
        "running":       true,
        "created_at":    "2026-05-22T10:30:00Z"
      }
    ],
    "degraded": [
      {
        "kind":  "deployment",
        "scope": "myapp",
        "name":  "broken",
        "error": "image pull failed: ..."
      }
    ]
  }
}

The degraded array lists deployments/statefulsets whose latest reconcile attempt failed. It's how the WebUI surfaces "this resource exists but isn't running" without the operator having to dig.

GET /api/pat/v1/pods/{name}

Per-pod detail: env vars (redacted), ports, mounts, network aliases, last 100 log lines, recent state transitions. {name} is the container name from /pods.

Returns 404 if no container with that name exists (most commonly: stale WebUI cache after a rolling restart changed replica IDs).

GET /api/pat/v1/pods/{name}/logs

Streams container logs. Query parameters:

ParamDefaultMeaning
followfalsetrue keeps the connection open + streams new lines
tail100last N lines to emit before live stream begins

Response is chunked-transfer (Transfer-Encoding: chunked) with Content-Type: text/plain; charset=utf-8. Each line is a single log record. The connection stays open until the client disconnects or the container exits.

POST /api/pat/v1/pods/{name}/restart

Triggers a rolling restart of the deployment / statefulset that owns the named container. The WebUI passes the container name from /pods; the controller resolves it to (kind, scope, resource-name) and dispatches to the existing restart machinery.

Requires actions scope. Subject to per-PAT rate limiting.

StatusMeaning
200restart accepted; the rollout proceeds asynchronously
400container's kind is not restartable (job / cronjob have no rolling-restart semantics)
404no container with that name
429rate limit exceeded

Resources

These routes need the manifests scope. Every route takes an explicit scope query value, and the PAT must hold it; a missing scope is 400. Mutations are rate limited like every action.

RouteDoes
GET /api/pat/v1/manifests?scope=&kind=the manifests of one scope, optionally of one kind
GET /api/pat/v1/describe?kind=&scope=&name=one resource: manifest and status, as vd describe
POST /api/pat/v1/applyapplies a manifest or a JSON array of manifests. ?dry_run=true, ?prune=true and ?force=true work as in vd apply.
DELETE /api/pat/v1/resource?scope=&name=removes a resource. &ordinal=N removes one statefulset pod; &prune=true also removes volumes.
DELETE /api/pat/v1/scope?scope=removes every resource of the scope
POST /api/pat/v1/restart?kind=&scope=&name=rolling restart
POST /api/pat/v1/rollback?kind=&scope=&name=rolls a deployment back
POST /api/pat/v1/pods/{name}/stopstops one container
POST /api/pat/v1/pods/{name}/startstarts it again

Config stays on /config with the config:read and config scopes.

Apply policy

The PAT plane is reachable from the network, so its apply accepts a safe subset of the manifest surface. The check is an allowlist: a field added to manifests later is refused here until it is reviewed.

AcceptedRefused
kinds deployment, statefulset, ingressjob, cronjob, asset, and plugin kinds (postgres, redis, …): a plugin kind can install a plugin, and an install runs code on the host
image, replicas, command, env, restart, health_check, volume_claims, depends_on, resources, logs, inits, probes, drain, autoscale, keep_releasesbuild (an image is required), volumes, env_file, network_mode, networks, cap_add, ulimits, docker_options, extra_hosts, env_from, on_deploy, on_probe, post_deploy, release
ports bound to 127.0.0.1:<host>:<container>any other port: ports = ["7419"] publishes on a random public host port
ingress host, service, port, tls, locations, lb, tcptls.on_demand and tls.ask

An ingress reaches its service on the voodu0 network, so a workload behind an ingress needs no ports.

Defense in depth

The split-listener architecture is not auth; it's another layer underneath auth. Even with a leaked PAT, an attacker reaches only the observability plane — never /apply, /diff, /secrets, or any mutation surface beyond rolling restarts. The orchestration plane refuses connections from non-loopback IPs by default.

Threat model for operators:

  • Lost laptop with SSH key → still need a PAT for the WebUI; revoke via vd pat revoke <id>.
  • Leaked PAT → bounded blast radius (restart-only mutations, rate-limited). Revoke + rotate.
  • WebUI host compromised → attacker has read access + restart-only actions across every registered island. Mitigate by minting separate PATs per WebUI deployment and firewalling :8687 to the WebUI host's IP.

Lifecycle

# Mint a PAT (orchestration plane, via CLI)
vd pat create --name="webui-staging" --scope="read,actions"
# → pat_a3F9bZ2k7Qm9pNvX4tCfH5d8yL2eRw   (shown ONCE)

# List existing PATs (hashes never returned)
vd pat list

# Revoke
vd pat revoke <id>

The token appears only in the create response. The list endpoint returns the redacted record (ID, scopes, name, timestamps) — never the hash, never the plain token. If you lose the plain, mint a new one and revoke the old.

On this page