Structure: swipe → for the next module, ↓ for depth within a module.
Written for a senior SWE with no Kubernetes / infra background — K8s concepts are introduced as K8S callouts when first needed.
mission-control-workspace) is 2 repos, 34 services, 11 volumes, 2 PostgreSQL databases — far too big to run on a laptop.
./preview-local bootstrap # one-time: builds its own venv
./preview-local cluster configure --host … --user …
./preview-local project configure mission-control-workspace \
--definition …/mission-control-workspace.json \
--component mission-control=…/mission-control …
./preview-local dev session my-feature \
--project mission-control-workspace \
--component mission-control=…/task-worktree --background
# … edit code locally; changes stream to the running env …
./preview-local dev urls my-feature # http://…:8080/… via SSH tunnel
./preview-local dev smoke my-feature # scripted HTTP checks
./preview-local dev down my-feature # stop (state kept)
./preview-local dev purge my-feature # destroy everything, with receipts
Everything below this slide explains what those commands actually do.
Just enough Kubernetes to read the rest of the deck. If you know K8s, swipe right.
metadata (name, labels = indexed key/value tags, annotations = unindexed notes), a spec (desired) and a status (observed).PreviewEnvironment.kubectl get/create/apply/delete …. Reads a kubeconfig file for the server address + credentials.
ssh -F /dev/null -o BatchMode=yes -o StrictHostKeyChecking=yes user@host \
'/usr/bin/env KUBECONFIG=… kubectl get previewenvironments …'
ownerReferences on "its" object is treated as evidence of a foreign collision.Everything user-initiated flows left→right. The operator alone touches runtime objects; the client never creates a pod.
| Authority | Owns |
|---|---|
| Git repositories | committed source history |
| Your worktree | the current dirty (uncommitted) source |
preview-source-agent | server-side source materialization (worktrees on the host) |
| Environment Manager (per alias) | lifecycle/source coordination + its private recovery journal |
PreviewEnvironment CR | durable desired runtime intent |
preview-fabric-operator | the only durable runtime reconciler |
| Kubernetes | current workload / network / Secret / Job / PVC reality |
Every design decision below is some form of: keep these authorities from stepping on each other without introducing new identity or auth systems.
installationID (e.g. preview-fabric-system) is a collision scope, not identity — it namespaces object names so two installations can share a cluster.(ownerID, ownershipEpoch, incarnationID, incarnationSequence) naming exactly which manager process may mutate an environment. Stale processes are "fenced": their writes are rejected by both the source agent and the CR schema. Cooperative fencing, not authentication.| Store | Schema | Contains / never contains |
|---|---|---|
| Project definition (tracked in repo) | project/v4 | components, services, tiers, volumes, secrets decl., databases |
cluster.json (machine) | cluster/v1 | SSH host/user, ports, installationID — no secrets |
| Client binding (per alias) | client-binding/v1 | paths, receipts, watcher identity — never source bytes/secret values; 512 KiB cap |
| Manager journal (per alias) | SQLite WAL | request/receipt identities for crash recovery — never a workflow program counter |
| Source agent store (host) | source/v2 | bare repos, worktrees, receipts, outcome log, tombstones |
PreviewEnvironment CR | v1alpha1 | complete runtime intent + bounded observed status |
| State cache (host) | dirs + marker | finished dependency volumes, content-addressed — no owner, no reconciler |
./preview-local is a 96-line self-bootstrapping Python launcher: creates .preview-local-venv/, installs pinned deps (requirements/runtime.lock), then os.execv-re-executes itself inside the venv.anyio, pydantic, watchfiles, tzlocal, idna. ~27 k lines of Python across 34 modules.systemd-run --user (Linux), launchd (macOS), or a plain detached-process fallback. Process identity is (pid, boot-id, start-ticks) so a recycled PID is never mistaken for a live worker.| Group | Commands |
|---|---|
| setup | bootstrap · doctor · cluster configure/status/previews/image-load/tunnel · project configure/list/status |
| lifecycle | dev create / up / down / purge / sync / session / restart / lease |
| observe | dev status / urls / smoke / logs / diagnose / manager-status / gc |
| escape hatches | dev shell / forward / remote publish / secrets apply / clone retry / takeover |
| baseline | dev main up / down / ensure / status / reset |
| low-level | dev plan / ensure / wait (the raw protocol, exposed) |
--json for machine output, --state-root, --config-root. Mutating commands accept --detach; default wait timeout 120 s.my-feature) is the developer-facing name of one environment; everything else derives from it.ingress_node_port 30080, local_preview_port 8080, gateway names, installation_id. Fail-closed validation on every field.
.preview-local-state/): client-owner ID, per-alias binding (bound paths, last receipts, watcher identity), flock files, manager journal + IPC sockets.preview-fabric-manager-ipc/v2, length-prefixed canonical JSON ≤ 1 MiB, peer-uid checked, 8 worker threads).A timeout never retries by itself; an interrupted Ensure is resumed under its original identity, not re-planned. This is the idempotency spine of the whole client.
Prepared
-> FenceClaimed # writer fence current on source agent
-> SourcePartiallyAccepted | SourceAccepted
-> APIAccepted # CR write accepted by API server
-> OperatorAcknowledged # operator echoed the proposal
-> Terminal
dedupeUntil. A lost response is resolved by querying the retained outcome, adopting only the exact recorded result.wait can distinguish my convergence from someone else's (Succeeded / Superseded / Blocked / PLAN_STALE / Deleted…).watchfiles (Rust notify backend → inotify on Linux: the kernel pushes file-change events; no scanning)..git, git-ignored trees (node_modules…), declared watch_excludes, secret paths, .previewignore matches. Budget: 100 000 watches.git check-ignore; creating a directory or editing .gitignore/.previewignore triggers a full watch-set rebuild./proc/self/mountinfo and re-enables inotify when all roots are on native Linux filesystems.watch_excludes only suppresses triggers; a later manual sync can still deliver those paths. .previewignore excludes from both.git diff --name-only HEAD → tracked editsgit ls-files --others --exclude-standard → untracked filesinclude_ignored force-includes".", which downstream reads as "do a full reconciliation". Overflow degrades to a slower-but-correct path, never an error.submit() never blocks the watcher.full_reconciliation_required instead of growing memory.source-submit + source-publish (the hot-reload lane).RUNTIME_AFFECTING_SOURCE_REQUIRES_ENSURE (the edit touched declared config/dependency inputs), escalate to a full sync, exponential backoff ≤ 60 s.dev down clears the watcher token in state; the loop notices and exits cleanly.The watcher is a pure input producer: it can keep ingesting while the operator is slow, because the source lane and the runtime lane are independent.
preview-source-agent is a Go binary in a 1-replica Deployment. Its long-running container just sleeps (daemon verb). It mounts one hostPath: /var/lib/preview-fabric/source.
ssh user@host -- 'kubectl exec -i -n preview-fabric-system \
deployment/preview-source-agent -- /preview-source-agent serve'
preview-fabric-source/v2
PFSOURCE/2\n # magic
[u32 big-endian header length]
{ canonical JSON header, ≤ 1 MiB, unknown fields rejected }
[bundle bytes … declared size + sha256]
[overlay bytes … declared size + sha256]
dedupeUntil, writer fence, component, repository key, monotonic sequence, expectedReceipt (CAS), commit, sparse patterns, overlay/deleted/required paths, semantic inputs.ingest · status · publish · remove · claim · takeover · writer-status · outcome · purge · purge-status.unsupported-protocol — before any store initialization.pack.window=0 pack.depth=0 pack.threads=1) — the bytes are hashed into the receipt, so they must be reproducible.writer-status advertises up to 32 repository tips the agent already has; the client builds git bundle create <rev> --not <tips> — a new env at the baseline tip ships a ~642-byte bundle instead of full history.bundle-basis-unknown before touching anything; the client retries the same request ID with a full bundle. Tip presence doubles as the capability signal — old agents reject the unknown header field.objects/info/alternates); git runs with hooks and credentials disabled, auto-gc off..git, symlinks confined) → apply deletions → check required paths.SOURCE_ROOT/worktrees/INSTALLATION/ENV_UID/COMPONENT — same path, new bytes. This is what makes hot reload free: pods mount that path; no K8s object changes.| Mechanism | Protects against |
|---|---|
Writer fence (owner/epoch/incarnation/seq); explicit takeover only | a stale manager process writing after a new one took over |
| Lock order: environment → component → repository (flock) | a takeover landing between fence-check and finalization |
Receipt CAS (expectedReceipt) + monotonic sequence | lost-update races; out-of-order delivery |
| Outcome log keyed (request ID, digest), ≤ 24 h dedupe, 2 GiB budget | replays after lost responses → returns the original outcome (alreadyApplied) |
Same ID + different payload → request-id-reused | accidental identity reuse |
| Retired-UID tombstones (forever) | a zombie manager resurrecting a purged environment |
| Capacity exhaustion rejects before mutation | evicting an unexpired idempotency promise |
/var/lib/preview-fabric/source/
├── repositories/INSTALLATION/REPOKEY.git # one shared bare repo per repo
├── worktrees/INSTALLATION/ENV_UID/COMPONENT # ← pods mount exactly this
├── receipts/ writers/ retired/ purges/
├── outcomes/ # 256-shard append-only idempotency log
└── .staging/ .finalizing/ locks/
unpublished ref before overwriting.dev remote publish ALIAS COMPONENT asks the agent for a bundle of it and imports it locally at refs/preview-fabric/<alias>/<component> — no checkout, no merge, no branch change. Normal git workflow takes over from there.PreviewEnvironment CRDpreview.preview-fabric.io/v1alpha1, kind PreviewEnvironment, cluster-scoped (lives outside any namespace) — so the operator can derive and own the environment's namespace and detect collisions before creating anything.
CR name: pe-<alias>-<sha256(installation┃project┃alias)[:10]>
namespace: pf-<inst≤11>-<proj≤16>-<alias≤20>-<hash(…+CR-UID)[:5]>
The namespace hash includes the CR UID → a re-created same-name environment gets a different namespace; the old one is left untouched.protocolVersion: v2, initialization phase Claiming + a random client-binding nonce + claimExpiresAt (15 min), desired state Stopped, and — enforced by CEL — zero sources/services/PVCs/routes/clones.Claiming → Active (one-way) together with the first complete runtime proposal.ensureRequestID, planID, activationID (hashes of the plan), dedupeUntil, and the source receipt vector (component → receipt). CEL guarantees an ID can only ever be reused with a byte-identical payload.| Field | What it declares |
|---|---|
installationID, alias, project{id, role, baselineRef, tier} | identity + Feature vs Baseline role (immutable) |
lifecycle{writer, initialization, runtimeProposal, serviceActions, cloneActions} | the v2 cooperating-writer protocol |
desiredState | Running | Stopped — the only imperative-ish knob |
lease{expiresAt, pinned, retention} | absolute expiry; reconciled in-cluster even with all clients offline |
sources[] | per component: worktreePath (the only field entering PodSpecs) + receipt/commit/fingerprints (observational) |
services[] (≤128) | type Deployment/StatefulSet/Job, full PodTemplate, ports, sourceMounts, pvcMounts, stateCacheMounts, configFingerprints, dependencyFingerprints, dependencies (Started/Ready/Completed, propagateRevision), externalEffect contract for Jobs |
pvcs[] routes[] networkPolicies[] databaseClones[] applicationChecks | storage, HTTP routing, isolation, data cloning, post-convergence validation |
status.observedGeneration == metadata.generation means "the operator has seen this spec" — not that it succeeded. (generation increments on every spec change.)Pending → Reconciling → Ready / Stopped, plus Degraded, Blocked (ownership conflict), GarbageCollected, Terminating.ProposalAccepted · Ready · Progressing · Degraded · OwnershipConflict · LeaseExpired · DatabaseClonesReady · RoutesReady · Validated.lifecycle.currentTarget / lastAppliedTarget: the activation being converged, with expected child revisions — what a precise wait correlates against.| Rule | Effect |
|---|---|
installationID, alias, project id, nonce immutable | identity can never drift |
| ownership epoch advances by exactly +1; ownerID immutable within an epoch | takeovers are explicit and ordered |
| incarnation sequence strictly monotonic | stale process writes rejected at the schema |
| Claiming skeleton must be Stopped and empty | no runtime work before identity is fenced |
| reuse of ensureRequestID / activationID / actionID requires identical payload | idempotency by construction |
Analogy: database CHECK constraints for a distributed handshake.
Reconcile(ctx, req) function. 1 replica + Lease-based leader election; 64 Mi RAM requested.PreviewEnvironment (only spec-generation / finalizer / deletion changes — status writes don't self-trigger), and watches every child kind (Deployment, StatefulSet, Job, PVC, Service, Secret, NetworkPolicy, HTTPRoute), mapping child events back to the owning CR via labels, since ownerReferences don't exist here.installationID isn't its own → two installations coexist.render() function: props = the CR spec; the virtual DOM diff = the effect plan; the DOM = the Kubernetes API. It re-renders on every input change, on a timer, on restart — so it must be pure at the decision layer and idempotent at the effect layer.RequestIDReused · OutcomeExpired · EffectPlanUnbounded · OutcomeCapacityExhausted · WriterFenceRejected · InvalidDeclaration.dev restart)⇒ an ordinary code edit changes zero Kubernetes objects. The worktree path is stable; the agent swapped the bytes underneath. That is the entire hot-reload trick.
The operator also computes one runtimeIntentID — a sha256 over the canonical JSON of the whole runtime spec — the sole authority for "did the intent change?" (the client's copy is advisory; golden tests pin Go and Python to byte-identical encoding).
| Object | Notes |
|---|---|
| Namespace | derived name incl. CR-UID hash; created once, never deleted |
| Deployment / StatefulSet | per service; selector {environment-uid, component}; revision stamped as label (12 hex) + full annotation; Stopped ⇒ scaled to 0, never deleted |
| Job | immutable, revisioned: name = <svc>-<revision[:12]>; new revision ⇒ new Job, old one pruned; failed Jobs kept as evidence; effect identity injected as env vars |
| Service | for non-Job services with ports; ClusterIP preserved on update |
| PVC | retention policy Delete | Retain — retained claims are released (labels stripped), not deleted |
| HTTPRoute | attached to shared Gateway preview-system/preview-fabric#web; hostname from template |
| NetworkPolicy | as declared |
| Validation Job | strictly-decoded executor template; ≤3 attempts, 120 s backoff, real failure recovered from the pod termination message |
Sources and the state cache enter pods as operator-derived hostPath volumes; raw hostPath in user PodTemplates is rejected outright.
ownerReferences + exact managed-by / installation / project / environment-name / environment-UID labels + installation / environment-UID annotations + (for namespaced children) the status-recorded namespace UID.Blocked, condition OwnershipConflict, retry every 30 s. The operator repairs nothing it cannot prove.| Constant | Value |
|---|---|
| effect retry interval / rollout probe | 1 s / 2 s |
| ownership-conflict retry | 30 s |
| requeue clamp | 1 s … 30 min |
| default retention after lease expiry | 72 h |
| max effects per plan / terminal outcomes / child deletes per pass | 256 / 128 / 64 |
| validation: attempts / backoff / deadline | 3 / 120 s / 300 s (max 600) |
| services per env / sources / clones | ≤ 128 / 32 / 32 |
| RBAC oddities | pods: get/list only; secrets: never created (delete for cleanup only); namespaces: no delete verb at all |
Every list is bounded, every retry is bounded, every message is truncated (512 B). Nothing in the operator can grow without limit — a deliberate posture.
.env files, TLS dirs — declared in the project definition, paths overridable per machine)..env / filesdev secrets apply ALIAS — the explicit path after editing a secret: in-memory no-op detection; a change journals new revisions and submits one freshly recomputed complete runtime proposal.postgres-password, postgres-dsn, neo4j-auth) compute connection materials from declared inputs — still client-side.PreviewEnvironment per project with role Baseline (alias main): built from the canonical main branches, no lease, no watcher, excluded from retention. It is the hot template every feature clones data from.dev main ensure — one routed command: create if absent, exactly resume an interrupted create, activate a Stopped one, then submit a single sync. A merge to main reaches the baseline with no destructive step; untouched inputs classify no-op and write nothing.dev main status — read-only freshness: per component, the commit the baseline was ensured at vs the canonical checkout's HEAD now. Fails closed (unreadable checkout ⇒ "not fresh").project.baselineRef → the exact baseline CR (same installation + project).clone-<name>-<revision[:12]>. Failure stays visible as evidence; the operator never auto-retries.dev clone retry ALIAS CLONE: a target-scoped action naming the exact failed revision; the action ID becomes part of the new Job's revision. Replaying the same action attaches; changed payloads are rejected.pg_dump --format=custompg_restore --clean --if-existsIdempotent — rerunning yields the same result.pg_basebackup from baselinemkdir(2) markermkdir: same identity + complete stamp ⇒ attach and exit; anything else (different revision, in-progress stamp, populated dir without stamp) ⇒ visible refusal. A seed never wipes a data directory.dev status / dev logs; silent retries would destroy the trail and can mask data corruption.dev main ensure.DatabaseClonesReady + per-clone status (mode, revision, phase) tell you exactly where things stand.Gateway (preview-system/preview-fabric, listener web). Each environment publishes HTTPRoute objects with hostname templates like {alias}.mc.preview → hostname-based routing to that env's Services.
ssh -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8080:127.0.0.1:30080 user@host
loopback→loopback only, local 8080 → the ingress NodePort 30080.cluster tunnel --background/--status/--stop; health = unit alive and the port actually bound. One tunnel serves all environments (routing is by Host header).Ready with the tunnel down, and tunnel failure never touches a PreviewEnvironment.dev urls ALIAS — prints each route as http://<hostname>:8080/… for use through the tunnel.dev smoke ALIAS — GETs each route's declared smoke checks against 127.0.0.1:8080 with a spoofed Host: header; exit codes distinguish check-mismatch / operational failure / tunnel-unavailable.dev forward ALIAS SVC — for non-HTTP ports (e.g. postgres): one command that runs ssh -L and a remote kubectl port-forward chained; lives while the process lives.dev shell ALIAS — kubectl exec -it into the tier's dedicated dev-shell workload (a container with your source mounted — where an in-env agent can work and commit; cf. dev remote publish).dev logs ALIAS SVC — label-selected pod logs, remote kubectl.Optional | Required; correlation RuntimeOnly | ExactSource (the check proves it saw this exact source receipt, or reports BehaviorPassedSourceUnproven).Validated condition — the strongest "your env truly works" signal, consumed by dev diagnose --gate validated.dev session my-feature → manager plans; for DB projects, first Ensures the baseline (accepted, not Ready).claim. Operator echoes WriterPending; creates nothing.…/ENV_UID/component → receipts + semantic fingerprints.Ready; a RuntimeActivation terminal outcome lands in the ledger; the manager's wait resolves against exactly that receipt.dev down → desiredState: Stopped: workloads scale to 0, Jobs/routes go away, PVCs and Secrets stay — dev up is cheap.lease.expiresAt (renewal computes a business-day deadline in your current timezone, then stores the absolute instant — changing timezone later can't move it). Baselines, pinned envs, and no-expiry envs are protected.LeaseExpireddeleteAfter = expiresAt + retention (default 72 h)GarbageCollecteddev gc is a read-only view — there is no client-side cleanup daemon.purge: exact env UID + fence + complete receipt vector + request ID + dedupeUntil. Agent validates all receipts, writes a durable purge tombstone, removes source, and retires the UID forever.purge-status; a same-name successor has a different UID and can never satisfy the old tombstone. A stale manager can never recreate source for a retired UID.Blocked, not silent mixing;Measured cost order for a cold create (descending):
The obvious alternative — a pool of pre-warmed claimable environments — was explicitly rejected: claiming is adoption with a protocol; the alias is baked into immutable names; secret revisions would roll every warm pod anyway; idle spares burn RAM on one shared host.
/var/lib/preview-fabric/state-cache/
└── INSTALLATION/PROJECT/deps/<DEP_FINGERPRINT>/<VOLUME>/
├── .preview-fabric-complete # marker written before publish rename
└── node_modules/ … # finished dependency tree
.staging.* sibling) · published. No partial published state (marker exists before the atomic rename(2)); concurrent publishers resolve via kernel ENOTEMPTY.cp --reflink=auto: metadata-cost copy-on-write on capable filesystems (XFS/btrfs), silently a plain copy on ext4. An accelerator, never a dependency.| Change | Mechanism |
|---|---|
| uv replaces pip | lock generation + installs; 26 s vs 68 s measured. Invisible to app teams (requirements.lock is a fabric artifact) |
| volume co-location | install target + package cache on one volume — hardlink/reflink across PVCs fails with EXDEV (cross-device); co-location makes them cheap again |
| incremental dependency reconcile | Jobs converge instead of wipe: stamp match ⇒ skip · populated + mismatch ⇒ npm install + prune / convergent uv · empty ⇒ npm ci — keyed by a digest stamp file on the volume |
| incremental git bundles | (Module 5) first push of a new env is delta-only vs advertised tips — this killed the need for a server-side "worktree fork" op |
| hot baseline | dev main ensure keeps the template warm; refresh not charged to feature creation |
| PhysicalSeed | ships, but data-driven off for mission-control (dump too small to matter) |
Also fixed along the way: the additive-rollout defect (a service added to the project definition previously couldn't converge onto a live environment through any non-destructive operation), and stale receiptless syncs now terminalize as PLAN_STALE.
| Layer | What |
|---|---|
| Golden vectors (cross-language) | Python must reproduce Go's canonical JSON byte-for-byte for runtimeIntentID, namespace names, claiming shapes — paired Go+Python tests over shared fixtures; a golden diff is a contract change needing individual justification |
| Python (~40 files) | real git repos in tmpdirs; watcher on a real filesystem; wire-protocol framing; journal recovery; docs-drift tests |
| Go | race-enabled unit tests; envtest (a real kube-apiserver + etcd, no nodes) for reconcile logic; differential fuzz of defaulting/pruning; a 100 k-outcome volume test |
| e2e (kind) | two suites in CI, everything digest-pinned |
Dockerfile.e2e-sshd builds an Alpine sidecar running sshd (per-build host keys, pubkey-only) with kubectl + a rewritten kubeconfig inside; cluster configure points at it. The client can't tell it from a real host.HOME, config/state roots, process supervisor); phases P0–P8 cover transport, bootstrap, create, fact assertions, hot path returns without pod replacement, watcher-observed edit sync, receipt-CAS purge, supervision teardown — with wall-clock timings printed per phase.make verify && make build && make images → push both images → record digests → ./scripts/operator-install.sh with digest-pinned image env vars (…@sha256:… enforced; tags refused).web; scripts refuse fixed-name resources that exist without exact installation labels; reset/uninstall demand exact confirmation + precondition deletions and never touch foreign resources.preview-fabric-system, the CRD (with a schema-floor annotation the client checks), RBAC, operator Deployment, source-agent Deployment. Uninstall retains the CRD, namespace, and any pull Secret.preview-local cluster _kubectl — the client's own SSH bridge.docs/operations/operator-runbook.md: status interpretation, source/database recovery, limitations.ownerReferences; UID-pinned everything.Further reading, in order: docs/architecture/overview.md → runtime-lifecycle.md → source-authority.md → docs/state-spaces.md → ADRs 0006–0010 → the operator runbook.