Skip to content

Shuttle ↔ Starbase API Contract

All requests include Authorization: Bearer <token> and Content-Type: application/json. (Design record: specs/2026-07-02-status-contract-and-tombstone-gc-design.md.)

§25.1 Pull Desired State

Load-bearing — the desired-state payload

This response body is the desired-state contract Shuttle obeys ("Starbase decides, Shuttle applies"). The payload shape is load-bearing; the canonical model lives at §32. Never reintroduce customer_id — identity is project_id + environment + service_id, with workspace_id as the billing-boundary label.

GET /api/v1/clusters/{cluster_id}/desired-state
If-None-Match: "<last applied sequence>"     → 304 Not Modified when unchanged (no-op tick, ~free)

Each cluster's payload contains exactly the entities placed on that cluster (FR-077) — Shuttle never sees or reasons about other clusters. Response:

{
  "sequence": 1234,
  "version": "abc123",
  "force_gc": false,
  "allow_cross_env_networking": false,
  "cluster_config": {
    "gateway_service_annotations": {
      "service.beta.kubernetes.io/do-loadbalancer-size-unit": "3"
    }
  },
  "services": [
    {
      "workspace_id": "...",
      "project_id": "...",
      "service_id": "...",
      "deployment_id": "dep_...",
      "environment": "production",
      "type": "web",
      "tier": "nova",
      "image": "registry.digitalocean.com/starform/...",
      "replicas": 1,
      "autoscaling": { "enabled": true, "min_replicas": 1, "max_replicas": 4, "target_cpu_percent": 75, "target_memory_percent": null },
      "env": {"KEY": "value", "API_URL": "http://api-production:8080"},
      "ports": [{"container_port": 3000, "protocol": "TCP"}],
      "public": true,
      "hostname": "myapp-p9vq.starform.app",
      "health_check": { "path": "/readyz", "port": 3000, "timeout_seconds": 300 },
      "pre_deploy": { "command": "npx prisma migrate deploy" },
      "deleted": false
    }
  ],
  "databases": [
    {
      "workspace_id": "...",
      "project_id": "...",
      "database_id": "...",
      "environment": "production",
      "engine": "postgres",
      "tier": "mininova",
      "mode": "containerized",
      "image": "postgres:16-alpine",
      "resources": { "cpu": "250m", "memory": "512Mi" },
      "volume_gb": 1,
      "deleted": false
    },
    {
      "workspace_id": "...",
      "project_id": "...",
      "database_id": "...",
      "environment": "production",
      "engine": "valkey",
      "tier": "supernova",
      "mode": "dedicated",
      "endpoint": "private-db-valkey-nyc3-....db.ondigitalocean.com:25061",
      "secret_ref": "sys-db-<database_id>",
      "deleted": false
    }
  ]
}

Field semantics:

  • sequence — int64, monotonic per cluster; Starbase increments it on every desired-state change. Shuttle refuses a payload whose sequence is lower than the last one it applied (stale-read / split-brain guard, §27) and keeps the last-applied state. Restore recovery: the counter lives in the control-plane Postgres, so a DR restore (§39.3 #34) would otherwise re-issue lower sequences and freeze the whole fleet. On control-plane recovery Starbase re-derives each cluster's counter from the max sequence_observed that cluster reports (§25.4) and resumes one above it — automatic and per-cluster (the fleet, not Postgres, holds the high-water mark). For total loss (a cluster and Postgres both gone) an operator-authenticated, per-cluster sequence_reseed override re-baselines that cluster — a break-glass analogue of force_gc. version stays the opaque content hash used for no-op detection — two fields, two jobs.
  • gateway_service_annotations — an opaque, adapter-rendered map applied to the Envoy Gateway Service. Starbase's region adapter decides the keys and values (on DO, the CCM's LB-sizing annotation shown above; other clouds render their own CCM's vocabulary); Shuttle applies the map without interpreting it (§34.2) — the contract stays cloud-neutral.
  • deployment_id — the deployment that produced this service revision; Shuttle stamps it into the §24B.1 provenance annotations and echoes it in §25.4 status reports.
  • typeweb | worker (cron post-MVP). Web renders a ClusterIP Service and, when public, an HTTPRoute; worker renders neither and gets no probes (§20.2). Carried explicitly so Shuttle never infers exposure from ports/hostname.
  • public — web only; default true. false = internal-only: the Service is rendered, no HTTPRoute, no DNS record; siblings still reach it at http://<service-name>-<environment>:<port> (Services › Networking). Worker/cron ignore it.
  • env — Starform-injected env key/values for the pod, including a linked service's connection URL (default <TARGET>_URL, Connections). Customer Var Group values mount separately as Secret envFrom (§38.4).
  • autoscaling — when present with enabled: true, Shuttle renders a HorizontalPodAutoscaler and the HPA owns Deployment.spec.replicas (§20.3); replicas is then only the creation-time seed.
  • suspended — billing suspension (§36). When true, Shuttle scales the Deployment to 0 and removes any HPA, overriding autoscaling and the replicas seed — so suspension halts autoscaled services too, not just fixed-replica ones. The stored autoscaling config is untouched; clearing suspended restores the HPA automatically. Default false.
  • health_check — optional single check {path?, port, timeout_seconds}. With path, ready = HTTP GET returns 2xx; when null and the service is web, Shuttle defaults to a TCP-connect check on the first container port (never ready-at-process-start); workers get no probes. Rendered as startup/readiness/liveness per the §20.3 mapping.
  • pre_deploy — optional {command}. When present and deployment_id has changed, Shuttle runs the §20.3 pre-deploy Job (new image + the environment's Var Groups) and gates the Deployment update on Job success; failure ⇒ §25.4 rollout: "failed" with reason PreDeployFailed — the previous version keeps serving. A superseding deployment deletes a running Job first.
  • databases — the DB/cache entities for this cluster (Managed Databases). The customer-chosen tier determines mode server-side (Starbase decides; Shuttle never infers): containerized (Mininova) entries carry image/resources/volume and Shuttle renders Deployment + Service + PVC + system Secret; dedicated (Micronova+) entries carry only the endpoint + secret_ref — Shuttle renders the system Secret and the per-environment egress allowlist (FR-075), never the instance itself. Database endpoints are region-local (FR-078): they appear only in payloads of clusters in the instance's region.
  • allow_cross_env_networking — per-project opt-in (FR-079); Shuttle renders the relaxed (same-namespace) NetworkPolicy scope when true (§20.4). Default false.
  • Conditional GET — Shuttle sends If-None-Match with the last applied sequence; Starbase answers 304 when unchanged, so no-op ticks transfer nothing. (Long-polling on this endpoint is a seeded upgrade, §39.3 #55.)
  • Tombstones — deletion is explicit: an entity being deleted appears with "deleted": true until Shuttle confirms via §25.4 (deletion_confirmed), plus a 7-day Starbase-side cleanup backstop for permanently offline clusters. Absence is never a deletion instruction (FR-073).
  • force_gc — operator-set override that bypasses the §20.3 delete breaker for one tick's tombstones; auto-expires on the Starbase side and is audit-logged once §39.2 #19 lands.

§25.2 Post Snapshot Batch

POST /api/v1/clusters/{cluster_id}/snapshots
{
  "snapshots": [
    {
      "snapshot_id": "hash(project_id+service_id+pod_id+ts)",
      "workspace_id": "...",
      "project_id": "...",
      "environment": "production",
      "service_id": "...",
      "pod_id": "...",
      "tier": "nova",
      "phase": "Running",
      "snapshot_timestamp": "2026-04-08T12:34:56Z"
    }
  ]
}

§25.3 Post Capacity Report

POST /api/v1/clusters/{cluster_id}/capacity
{
  "cluster_id": "...",
  "current_pod_count": 423,
  "soft_limit": 1000,
  "timestamp": "2026-04-08T12:34:56Z"
}

§25.4 Post Status Report

Load-bearing — the upward half of the loop

Desired state flows down (§25.1); observations flow up here. Starbase derives the deploying → deployed | failed build-state transitions (§16.12) from these reports (FR-072 / SC-019), and tombstone acks + orphan reports + breaker state ride along (FR-073). Status is derived from the Informer cache — a dropped POST costs nothing; the next tick regenerates it.

POST /api/v1/clusters/{cluster_id}/status
Status report · POSTed by the Status Runnable (§19.3) every ≤10s on change
{
  "sequence_observed": 1234,
  "reported_at": "2026-07-02T12:34:56Z",
  "services": [
    {
      "workspace_id": "...", "project_id": "...", "environment": "production", "service_id": "...",
      "deployment_id": "dep_...",
      "observed_image": "registry.digitalocean.com/starform/app@sha256:...",
      "rollout": "failed",
      "replicas": { "desired": 3, "updated": 1, "ready": 2, "available": 2 },
      "reason": "CrashLoopBackOff",
      "message": "new-template pod restarted 4 times in 2m; 2 previous-version pods still serving",
      "first_ready_at": null,
      "deletion_confirmed": false
    }
  ],
  "databases": [
    { "database_id": "...", "environment": "production", "ready": true, "reason": null }
  ],
  "orphans": [ { "kind": "Deployment", "namespace": "proj-1f2e3d4c5b6a79880011223344556677", "name": "..." } ],
  "gc_breaker": { "tripped": false, "requested_deletes": 0, "threshold": 10 }
}
  • sequence_observed — the highest desired-state sequence this cluster has applied. Beyond the 304 conditional-GET check, it is the fleet-held high-water mark Starbase reads to re-derive its per-cluster sequence counter after a control-plane Postgres restore (§25.1) — so the fleet, not Postgres, is the source of truth for what has been applied.
  • rolloutprogressing | complete | failed, derived with the standard rollout-status logic: complete when observedGeneration == generation and updated == ready == spec.replicas; failed on the Deployment Progressing condition reason ProgressDeadlineExceeded, on a failed/timed-out pre-deploy Job (reason PreDeployFailed, §20.3), or ≥1 new-template pod waiting with CrashLoopBackOff / ImagePullBackOff / CreateContainerConfigError; otherwise progressing. (Shuttle sets progressDeadlineSeconds: 600 on every Deployment it renders — §20.3.)
  • first_ready_at — earliest readiness lastTransitionTime among ready pods whose image matches the desired digest; stateless across Shuttle restarts (read from pod conditions, not memory). This is the §16.12 deployed trigger: ≥1 healthy pod on the new image.
  • replicas.desired reads the live Deployment spec — HPA-mutated when autoscaling is enabled (§20.3) — so status stays truthful under autoscaling.
  • deletion_confirmed: true acks a tombstone (§25.1); Starbase then drops it. Re-seeing a tombstone for an already-gone entity re-acks as a no-op.
  • databases — readiness of containerized (Mininova) instances, derived from their pods like service status; dedicated instances are Starbase-side (Worker job state) and never appear here.
  • orphans — managed-labeled resources in the cluster with no corresponding payload entry. Shuttle never deletes them (§20.3); it reports them and a platform alert fires.
  • Delivery — the Status Runnable (§19.3) POSTs only when the batch's content hash changed, plus an unconditional resend every 5 minutes so Starbase can detect staleness; retry 3× then drop (regenerated next tick).

Starbase handling (API binary, §13):

  • Upsert last-known status per service; flip deploying → deployed when first_ready_at is set with observed_image matching the desired digest, deploying → failed on rollout: "failed" (previous version stays live, §16.12).
  • Stardeck's Deploy stage and service-health chips read this state.
  • Customer-facing only — the platform plane (Grafana Cloud, §35.5) keeps watching Shuttle's own health, not customer apps.

Cross-references

The HTTP client that speaks this contract → §19.3 · the desired-state model behind the §25.1 payload → §32 · the snapshot fields' identity tuple → §24.1 · the resources the §25.1 services array drives → §20.2 · snapshot delivery semantics & loss bounds → §19.4 · the Status Runnable that speaks §25.4 → §19.3 · tombstone GC + the delete breaker → §20.3 · sequence-regression and breaker failure rows → §27.