Skip to main content

App Lifecycle

vibeD owns one CRD, vibedapps.vibed.dev (VibedApp, short name vapp, API group vibed.dev/v1alpha1). It's the source of truth for a deployed app; the control plane reconciles it. Everything else reuses agent-sandbox's Sandbox / SandboxTemplate / SandboxClaim / SandboxWarmPool.

The VibedApp resource​

apiVersion: vibed.dev/v1alpha1
kind: VibedApp
metadata:
name: my-app
namespace: vibed-apps
spec:
owner: alice@example.com # authenticated identity (email or OIDC subject)
source:
tarballRef: s3://.../my-app.tar.gz # exactly one of tarballRef / gitRef
# gitRef: https://github.com/acme/my-app@<commit>
runtime:
lane: general # fast | general (classifier-chosen)
template: node-24 # classifier-chosen; override allowed
entrypoint: "" # optional start-command override; else autodetected
env: [] # [{ name, value }] passed to the user process
egress:
allowedHosts: # per-app outbound allow-list (default-deny)
- api.openai.com
- "*.internal.example.com"
resources:
cpu: "500m" # default 500m
memory: "256Mi" # default 256Mi
ttl: "30m" # idle TTL before suspend (default 30m; auto-suspend planned)
suspended: false # declarative suspend/resume toggle
status:
phase: Ready
url: https://6fcr8uffk2pd.example.com
sandboxRef: static-nginx-h7kzp # the bound agent-sandbox Sandbox
podIP: 10.244.0.42 # bound pod IP (for probe/inject, not routing)
routeTarget: vibed-app-6fcr8uffk2pd.vibed-apps.svc.cluster.local:8080
lastDeployedAt: 2026-05-22T10:47:23Z
snapshotRef: "" # S3 URL of latest Firecracker snapshot (planned)
conditions: [...] # Ready, SourceValid (metav1.Condition)

The app's public URL is derived from a stable 12-character label hashed from namespace/name, so the same app always gets the same subdomain.

Spec fields​

FieldTypeNotes
ownerstringAuthenticated user identity (email or OIDC subject). Used for AuthZ and per-user quota. Required.
source.tarballRefstringS3 URL of the gzipped source tarball.
source.gitRefstringGit URL + commit, used instead of a tarball.
runtime.lanefast | generalRuntime track. The classifier picks this; user override is rare.
runtime.templatestringPrebaked template (e.g. node-24, python-313, static-nginx). Empty falls back to the classifier default for the lane.
runtime.entrypointstringOverrides the autodetected start command in vibed-agent.
runtime.env[]{name,value}Environment variables exposed to the user process.
egress.allowedHosts[]stringDestination hostnames the app may reach. A leading *. matches one or more leading labels (*.example.com matches a.example.com). Empty means the app can reach no external hosts.
resources.cpustringK8s quantity, passed through verbatim. Default 500m.
resources.memorystringK8s quantity, passed through verbatim. Default 256Mi.
ttlstringIdle TTL before suspend-to-snapshot, Go duration syntax (30m, 1h). Default 30m.
suspendedboolDeclarative suspend/resume toggle (see Suspend & resume).

source must set exactly one of tarballRef or gitRef; the controller rejects a spec that sets both or neither and surfaces the reason on the SourceValid condition.

Egress​

The per-app egress policy is default-deny. Only the hosts in spec.egress.allowedHosts, plus vibeD's own system hosts (e.g. the source store), are reachable. Matching is on the CONNECT host / HTTP Host at the egress authorizer that fronts the per-connection Squid forward proxy. An empty list means the app has no external egress at all.

Phases​

Pending → Claiming → Starting → Ready ⇄ Suspended
↘ Failed

status.phase is the coarse-grained lifecycle state (+kubebuilder:validation:Enum=Pending;Claiming;Starting;Ready;Suspended;Failed).

PhaseMeaning
PendingThe CR exists; the controller hasn't started work.
ClaimingA SandboxClaim was created; waiting for agent-sandbox to bind an idle warm sandbox.
StartingA sandbox is bound. The controller probes vibed-agent (:9000), then POSTs the source URL to inject + start the user process.
ReadyThe user process is listening. A per-app Service exists, routeTarget + url are set, and vibed-router has programmed the Caddy route.
SuspendedCompute released. The SandboxClaim is deleted, podIP/routeTarget are cleared, and the pod is returned to the warm pool.
FailedValidation failed, or the agent couldn't start the app. conditions carry the reason.

Status fields​

FieldSet whenNotes
phasealwaysOne of the phases above.
urlReadyPublic URL of the running app. Empty in Pending/Claiming/Starting.
sandboxRefClaiming onwardName of the bound agent-sandbox Sandbox.
podIPbound onwardIn-cluster IP of the bound Sandbox pod. Used to reach vibed-agent (:9000) and inject source. Cleared on Suspended. Not the routing target.
routeTargetReadyStable upstream (host:port) vibed-router proxies to. See Routing target.
lastDeployedAtReadyTimestamp of the last transition to Ready.
snapshotRefon first suspendS3 URL of the latest Firecracker snapshot; used to fast-path redeploys via snapshot-restore. Planned.
conditionsalwaysStandard metav1.Condition list, keyed by type.

Conditions​

vibeD reports two condition types on status.conditions, using the standard metav1.Condition shape so consumers can match on Type/Status/Reason:

TypeTrue meansCommon reasons on False
SourceValidspec.source resolves to exactly one of tarballRef / gitRef.SourceMissing, SourceAmbiguous
ReadyThe app is serving traffic.Claiming, Starting, TemplateInvalid, TemplateMissing, ClaimFailed, AgentUnreachable, InjectFailed, ServiceFailed, RouterFailed, Suspended

Suspend & resume​

spec.suspended is the declarative toggle the controller reconciles. Suspend is release & re-materialize: setting spec.suspended=true (or POST /v1/apps/{app_id}/suspend) deletes the SandboxClaim (returning the pod to the warm pool), parks the app in Suspended, and clears podIP/routeTarget. Clearing it (POST /v1/apps/{app_id}/resume) re-enters the normal Claiming → Starting → Ready machine, re-claiming a pod and re-injecting from the stored source.

In-memory and on-disk state is not preserved, which is appropriate for stateless web apps. Real Firecracker memory snapshots via status.snapshotRef, and automatic idle-TTL suspend driven by spec.ttl, remain future work.

Routing target​

status.podIP is the bound pod's IP; the controller uses it to probe and inject, but it is not the routing target because pod IPs change on restart. vibed-router proxies to status.routeTarget instead:

  • general lane: the cluster-DNS name of a per-app Service that re-selects the bound pod across restarts, so it survives pod-IP churn.
  • fast lane (workerd): the worker's host:port.

When a sandbox pod is recreated, the Service follows it and the controller re-injects source, so the app self-heals without a route change. The router falls back to podIP when routeTarget is unset.

HTTP endpoints​

The /v1 API drives the lifecycle above:

MethodPathEffect
POST/v1/deployClassify source, create the VibedApp, drive it to Ready.
GET/v1/appsList the caller's apps.
GET/v1/apps/{app_id}Fetch one app (spec + status).
DELETE/v1/apps/{app_id}Delete the app and its sandbox.
GET/v1/apps/{app_id}/logsSSE live log stream.
POST/v1/apps/{app_id}/redeployRe-inject the latest source into a fresh sandbox.
POST/v1/apps/{app_id}/suspendSet spec.suspended=true.
POST/v1/apps/{app_id}/resumeSet spec.suspended=false.

Implemented vs. planned​

Implemented: the deploy → Ready path, GET/DELETE of apps, POST /v1/apps/{app_id}/redeploy, GET /v1/apps/{app_id}/logs (SSE live log streaming), and POST /v1/apps/{app_id}/suspend + .../resume. Still design targets: real snapshot-restore (status.snapshotRef) and automatic idle-TTL suspend driven by spec.ttl.