πSpindrift
Spindrift is the deploy control plane in
apps/spindrift/. No installation is declared underclusters/today β the Terraform substrate an installation federates into is still declared underterraform/, and the cluster declarations come back by reverting the clusters-teardown PR. Standing an installation up from nothing β Terraform bootstrap through first enrolment β is Runbooks/Install Spindrift. Installed, it is a platform workload and never one of its own Apps.Ownership boundary
Platform desired state remains GitOps-first. Flux owns the shared operators and the authentication proxy under
clusters/today, and β for an installation β its control-plane namespace, admission policy, target namespace and RBAC, and edge workload.Spindrift talks directly to delegated APIs after those prerequisites exist. On Kubernetes it owns the delivery object for each of an App's Components, in that App's own namespace; in GCP it owns App resources inside the App's pre-provisioned vessel. It never creates a boundary: no cluster, project, VPC, tunnel, signing key, or policy engine.
Inside a vessel it was handed, an App's resources live in a namespace of the App's own. It is created by the Target's delivery mechanism where that mechanism can carry the vessel's admission labels, and by Spindrift where it cannot: Argo carries them in
managedNamespaceMetadata, while Flux'screateNamespacetakes no metadata at all, so on a Flux Target Spindrift applies theNamespaceitself and stamps it with the Pod Security labels it reads off the Target's declared namespace. Flux still owns what admission means on a cluster, and one edit there reaches every App. Deleting the App deletes its namespace, and nothing else does β unplacing a Component leaves it, because its siblings live there. The sweep refuses any namespace not carryingapp.kubernetes.io/managed-by: spindrift, and skips a namespace a second App of the same name shares.Operators and agents still author platform changes in git. Direct API reconciliation is authority granted to the running Spindrift controller, not a second manual apply path.
Identity and targets
The installer chart at
packages/charts/spindrift/gives both processes the same audience-bound projected tokens. The web process holds them because it needs them: log tailing and the connect screen's cluster probe both cross the deploy-adapter seam. An offsite installation's Kubernetes token reaches the local API and federates to folly through the folly API server's OIDC claim validation innix/services/k8s/, which accepts the installation's ServiceAccount subject.The reconciler's GCP token uses the offsite provider in the
fml-poolworkload identity pool. The chart renders an external-account credential file for Application Default Credentials; IAM grants name the exact offsite Spindrift ServiceAccount subject.Each Target cluster declares a shared least-privilege target surface beside the installation: the namespace whose admission labels every App namespace is stamped from, the namespace Datastores are provisioned into, the App chart's
OCIRepository, and RBAC limiting source inspection to the two named chart sources an installation can be configured with and discovery to the capabilities Spindrift reports. Delivery is aClusterRolebecause an App's namespace is named for the App and there is no one namespace left to bind in; the datastore verbs stayed a namespacedRole, which is a smaller total grant than moving all of it up. There is still nosecretsrule anywhere in it, which is what makes a cluster-wide binding something other than read every credential on the cluster.A Datastore's credential reaches an App across that namespace boundary through external-secrets, because a
secretKeyRefcannot cross one. The mirror is not a copy Spindrift holds: the datastore operator still owns the value and its rotation, and ESO reconciling the mirror is what carries a rotation to the next pod with no Deploy.
Vessel and supply chain
Source is
docs/assets/spindrift-vessels.d2. Terraform and Atlantis own every boundary in it and Flux owns each cluster's platform prerequisites; Spindrift creates neither, which is why no arrow in it originates there.
A vessel is a tenancy boundary Spindrift deploys into: the thing that admits a call or refuses it, and that owns the federation the call is made with. A cluster is one, so
follyandoffsiteare each a vessel. A cloud project is one, sobluenoseis a vessel. A Vercel team is one, and a Cloudflare account is one. A Target is one runtime surface on a vessel β Kubernetes on a cluster, Cloud Run and static hosting on a project, Vercel on a team, Pages on a Cloudflare account β and a Target is what an App is placed on.apps/spindrift/README.mdcarries the model;apps/spindrift/src/domain/vessel.tsis where it is stated in code, and the surfaces each kind is probed for are the table there rather than a list here.The two edge Targets are the boundaries reached with a stored bearer rather than with federation, because neither platform offers inbound OIDC to exchange a projected token for. Each is one installation-wide value in the installation Secret (
SPINDRIFT_VERCEL_TOKEN,SPINDRIFT_CLOUDFLARE_TOKEN), never a column on a Target, and a Target whose token is missing or unscoped connects anyway and reads its checklist unmet. Both take afilesartifact and are handed a finished tree: the build runs on whichever route the Target's minimum SLSA level selected, and the deployment names no framework and no build command, so a rollback re-deploys rather than rebuilding.Spindrift never creates a vessel. Terraform owns every boundary and its platform prerequisites, Atlantis applies them, and Spindrift reports an absent or unauthorized one as an unmet prerequisite with the sentence behind it.
An unmet prerequisite also carries the Terraform that clears it and the root it belongs in, generated from what the probe observed. A vessel names its root in the installation manifest, and Spindrift opens the stanza as a pull request on this repository; Atlantis applies it and the standing check turns the row green. Nothing is enabled, granted, or created by Spindrift itself, and a prerequisite cleared outside Terraform says so rather than offering a change.
bluenoseis the home vessel: Spindrift's own project and the default shared one for Apps. The organization root adopts its project boundary;terraform/gcp/projects/bluenose/owns its platform prerequisites. Atlantis applies both roots.Build artifacts and signing stay in
trusted-builds. Spindrift can invoke builds and sign the one artifact digest; vessel runtimes receive pull-only access. Cloud Run is restricted to the vessel's enforcing Binary Authorization policy.Source is
apps/spindrift/src/web/client/diagrams/supply-chain.d2, which is also what the product's own Supply chain screen renders β it lives inside that package because the image build prunes to it.
Kyverno is installed on both clusters from
clusters/base/platform/kyverno/. An installation's image policy β aClusterPolicyrejecting an unsigned digest at admission with background reporting enabled, pinning the public half of thetrusted-buildssigner β ships with its cluster declarations.
Repository identity
Spindrift and Architecture/Bosun both speak to GitHub as installations of one shared GitHub App (
spindrift-bot, App ID4576122, owned by thejonpulsiferuser account). Atlantis and the Flux deploy-key App stay separate β a webhook is one-per-App, so folding either in would collide.That App is private, so it installs on
jonpulsiferand nowhere else: GitHub offers no account picker for a private App, andhomelab-ngcannot be reached from either GitHub's install page or the Repositories screen's install link.github.accountsin the offsite installation's manifest listshomelab-ngalongsidejonpulsifer, and that filter grants nothing on its own β the App's visibility is the blocker, and making it public is what anhomelab-ngrepository needs before it can be connected.Visibility is not on the App's General settings page, and after creation it is never labelled "Where can this GitHub App be installed?" β that wording exists only on the creation form. On an existing App the control is a Make public button under Advanced β Danger zone (
/settings/apps/<slug>/advanced). The move is one-way in practice: a public App cannot be made private again while it is installed on any other account.
An installation's own identity is resolved per mint, never captured at boot: either the installation Secret's
SPINDRIFT_GITHUB_APP_ID+SPINDRIFT_GITHUB_APP_PRIVATE_KEYpair (adopting an App that already exists), or agithub_approw sealed by GitHub's manifest flow at/internal/github/setup(creating one from the Repositories screen).apps/spindrift/src/integrations/github/app-auth.tsmints an App JWT and, per installation, an hour-lived installation token β the App's private key is the only long-lived credential, read fresh on every mint rather than cached at process start. There is no device flow.A repository connects by installing the App on its account and selecting it on GitHub; Spindrift re-enumerates through the App JWT rather than trusting anything GitHub's callback carries.
github.accountsin the manifest filters which installations the Repositories screen treats as connectable, since a public App can also be installed by strangers.The App-level webhook delivers to
https://spindrift-control.lolwtf.dev/internal/github/webhook, reached through a path-scoped rule on thespindrift-controlCloudflare tunnel (terraform/network/cloudflare) beside the bosun outbox rule. NoSPINDRIFT_GITHUB_WEBHOOK_SECRETmeans every delivery is refused, the same posture as no App identity at all.bosun authenticates as the same App with the same private key β the operator's choice, not a code constraint: GitHub Apps support a distinct key per consumer, but this fleet rotates one PEM for both, so rotating means updating every home together (1Password,
nix/secrets/bosun.sops.yaml, the installation Secret) and then deleting the old key on GitHub. See Architecture/Bosun.
Build routes
A Source becomes an Artifact on one of four adapter kinds β
github-actions,cloud-build,in-cluster,bosunβ each with its own contract and implementation underapps/spindrift/src/adapters/build/, and its own manifest shape inapps/spindrift/src/config/build-route-schemas.ts. An installation configures whichever routes it wants underbuild.routes, each with an operator-given name.Rank is the manifest's declared order, nothing else: the first configured route at or above a Target's minimum SLSA level wins.
apps/spindrift/src/domain/build-route.tsis the whole of that rule, and it never re-sorts what the manifest declares.bosunis the route ranked last on purpose: every other adapter is dialed directly, so a broken one is visible the moment a Build runs, while bosun is polled in over Architecture/Bosun's warm pool and keeps building while the Actions plane itself is down.On the
github-actionsroute a connected repository's caller (the oneconnectRepository's pull request writes) runs the reusable workflow atmainon that repository's own minutes, and the platform repository's caller runs it for archives and for repositories whose caller is not merged yet. The run's identity is the workflow it executes, not the repository it runs in: thehomelabpool admits any repository under the accounts the installation connects from while itsjob_workflow_refisspindrift-build.yml@refs/heads/main(terraform/gcp/projects/homelab-ng/workload-identity.tf), and the trusted-builds signer, the Artifact Registry, and the seal key are granted to that same principal. The private half of the route'ssealPublicKeylives in Secret Manager intrusted-builds(terraform/gcp/projects/trusted-builds/seal-key.tf); the platform repository's caller reads its own copy from a repository secret first, every other caller reads the store. A stored registry credential rides along sealed on every hosted build β the run may land in a repository whose own token cannot push to that registry β and the sealed login runs after the run's-own-token login, so it wins.
Config delivery
Spindrift writes config values into the installation's secret store and keeps only pinned references. Every Target the value is delivered on fetches it back itself, so a Target that cannot reach that store cannot hold a Component with configuration β placement says so rather than deploying one that comes up with an empty environment.
One store of record, several access paths. The store of record is Secret Manager in the
bluenosevessel. The paths differ by Target and the value does not: a Cloud Run revision resolves a pinned reference out of that project natively and can be pointed nowhere else, and each cluster reaches the same project through thegcpsmClusterSecretStoreinclusters/base/platform/gcp-secret-manager/, federating as its own ServiceAccount through that cluster'sfml-poolprovider. One store every Target shares is what makes a Component's placement a free choice between them.clusters/base/platform/onepassword-connect/stays the platform's own store β oauth2-proxy's credentials, the tunnel, the operators. It is a second store both clusters reach, and a Target reporting two stores still writes App config to the one the installation names.A Target's
ClusterSecretStoreis operator-stated in that Target's chart-values. The chart refuses to render config without it rather than producing an ExternalSecret that never syncs.Config that cannot follow a Component to another Target is named and demanded before the move commits, because Spindrift reads no value back and so cannot copy one. Between the two clusters nothing has to follow: they are two paths to one store, so the reference moves and no value does.
The store a running installation writes to is its stored row's. Nothing else declares one: moving it takes the same
configureInstallationthat carries every other manifest change.
Reach and auth
A Component states two independent facts:
reachisnone,private, orpublic;authisnoneorproxy.auth: proxywithreach: noneis refused β there is no route to filter.The App chart at
packages/charts/spindrift-app/renders an HTTPRoute whenreachis notnone, and the Gateway APIExternalAuthfilter exactly whenauthisproxy. The cross-namespace grant belongs to the shared proxy platform resources; the chart renders no Gateway and no certificate.The record type is the boundary.
reach: privatepublishes an unproxied A record at the shared Gateway's load-balancer address, which is RFC1918 and so is unreachable from the internet whatever is attached to it.reach: publicpublishes a proxied CNAME at the Target's Cloudflare tunnel, whose ingress is a static wildcard rule per zone that Terraform owns.An App's shared name is its vanity: one label under the zone, or
@for the zone itself (setAppVanity). It reaches every Target β on a cluster it rides the Component's own HTTPRoute and DNSEndpoint beside the canonical<app>-<component>.<zone>; on Vercel, Firebase Hosting and Pages the adapter attaches it as the project's domain β and it goes to an App's sole serving Component, because two claimants on one hostname is a collision the platform resolves arbitrarily. The address a release states β the App list's URL column, the workspace headline, a Deploy's own page β is that vanity wherever the deploy published one, with the canonical underneath it as the fallback. Spindrift publishes every record it is responsible for, the apex included, and nothing it mints is written into Terraform. A Target the platform names has no cluster of its own, so its vanity record is a DNSEndpoint Spindrift writes on the control-plane cluster (apps/spindrift/src/adapters/dns/), pointing where the adapter's verdict says the platform answers β<project>.pages.dev,cname.vercel-dns.comβ and withdrawn when the name or the placement goes; Firebase Hosting reports no such target and so publishes nothing. An apex record is the exception: it is published once and never re-pointed or withdrawn. external-dns marks the records it owns by prefixing the record type onto the name's first label, and an apex's first label belongs to the zone β so the marker for a bareexample.comlands outside every zone this installation holds and is dropped before it is written. Both the update path and the delete path filter on that marker, so a bare domain keeps pointing wherever it first went until somebody edits DNS by hand. The--txt-prefixshape that avoids this is documented upstream and changing it orphans the ownership of every record already published, so the limit is stated rather than moved:isApexNameis what the Domain control and the deploy log read to say so. Terraform keeps zone-level routing only: cloudflared's*.<zone>never matches the apex, so a cluster-served apex is ahostname = "<zone>"ingress rule on the Apps tunnel withpublish_record = false, andwwwis a redirect to the apex declared beside the zone, never a second name. Cloud Run serves no apex today: domain mappings are Preview and absent fromnorthamerica-northeast1, and a proxied CNAME torun.appneeds a Host override the zone's plan does not carry.Which zone a name is minted in is the App's, not the reach's. The installation manifest's
dns.zonesis a list, each entry naming a zone and the reaches it serves; an App pins one and unpinned Apps take the first entry serving their Component's reach. A pin a Component's reach outgrows falls through to a zone that serves it rather than publishing on a boundary the zone does not answer on.A zone in that list is a promise three other places keep: a wildcard listener and certificate on each serving cluster's Apps Gateway, a
domainFiltersentry on external-dns, and β forpublicβ an ingress rule on the tunnel interraform/network/cloudflare/spindrift.tf. A zone declared without them deploys green and resolves nowhere.An App's address answers from the moment the App exists, not from its first deploy.
clusters/offsite/apps/spindrift/status-route.yamlholds a wildcard route over the dedicated zone onto the control plane, andterraform/network/cloudflare/spindrift.tfpublishes the wildcard record that reaches it, so a name nothing serves yet lands on a page saying where that App is up to. It is lowest precedence by construction: a Component's own exact-hostname route outranks a wildcard at the gateway, as its record does in DNS. The zone isSPINDRIFT_DOMAIN, dedicated β a catch-all over a zone holding hand-managed names would answer for names Terraform owns.Each Target asserts which reaches it serves and which its authenticated edge can stand in front of. A Component asking for something a Target does not assert is a non-candidate at placement with a stated reason, rather than a green Deploy behind a route that answers nothing.
{reach: public, auth: proxy}is expressible and unmet on offsite:clusters/base/apps/oauth2-proxy/admits a single GitHub user, which is an honest edge in front of a private address and a false one in front of a public one.
Functions
A Function is one JavaScript
export default { fetch(request, env) }handler deployed as one public endpoint, and it is not an App: no Build, no Deploy row, no Vessel admission. The editor at/functionsholds the text, Run previews it in a BunWorkerwith a timeout, and Save pushes it to the function's own target β Cloudflare Workers as scriptfn-<name>answering on<name>.fn.<zone>with the installation's Cloudflare bearer, or a Cloud Run function on the home vessel with its federated identity, public through the Service'sinvokerIamDisabledbecause the organization policy forbidsallUsers. The row remembers the URL that came back and the last deploy error. A Workers hostname answers only once Cloudflare has issued its certificate β a few minutes after the first Save for a new name β and the editor polls the URL and says when it is live; live lines arrive over their own stream socket (/internal/streams/function-log), the same shape as the Deploy log tail on a separate connection. Source isapps/spindrift/src/functions/.The Cloudflare bearer is the Pages token widened by hand to Workers scopes β Account: Workers Scripts Edit, Workers Tail Read; zone: Workers Routes Edit, SSL and Certificates Edit, Zone Read β and still holds no DNS permission: the custom-domain record is Cloudflare's own side effect of the Workers API.
terraform/network/cloudflare/spindrift.tfdeclares the ownership boundary andterraform/gcp/projects/bluenose/holds the Cloud Functions prerequisites.A function carries an environment the handler reads as
env.NAME. Values are write-only β set once in the editor and never shown again by it, though a Run executes the editor's code against them β and rest as one envelope sealed with the installation keyring (SPINDRIFT_CREDENTIAL_KEYRING; without it the command refuses to keep values). Workers receive them assecret_textbindings; a Cloud Run function receives them as plain Service environment, visible to anyone who can read the project.Schedules and history are not part of it. A function that needs either becomes a Component.
kthx
Architecture/kthx is a neighbour, not a feature: its own process, its own namespace, its own database and bucket. What the two share is
packages/archive/β one reader for an uploaded tar or zip, one parser for ags://address β and the Apps Gateway that fronts both. Neither reads the other's rows.
Control-plane shape
Source is
apps/spindrift/src/web/client/diagrams/deploy.d2, shared with the product's Deploys screen the same way.
A release runs the web process, the reconciler, and their CNPG store. The reconciler supervises the target, config, deploy, build, manifest, and β where a repository integration exists β repository loops, each with its own retry chain.
Both charts under
packages/charts/are published as OCI artifacts by.github/workflows/spindrift-charts.yml, tagged with the version eachChart.yamlcarries. An installation pins each by that tag throughOCIRepositoryconsumers β the installer's beside its release, the App chart's on every Target cluster.A teardown is only ever what an act asked for by name. The deploy adapter's
destroyverb is called by unplacing a Component, destroying a Datastore, and deleting an App β each one's whole subject is the removal, and the delete reviews what it will tear down before it is confirmed. Disconnecting a Target calls nothing and strands what is running, deliberately: its subject is the Target row, and tearing down a live service is not what it says. A stranded workload is the operator's to reap by hand.
Linked references 12
kthx is quick sites: pick a name, drop a zip or an index.html, and it answers at https://<name>.kthx.dev β with a database, a socket, and a visitor identity of its own. The sites are public; claiming one is not: the control API answers on kthx.${SECRET_DOMAIN}, a name on the gateway's private address that the lab, the offsite LAN and the tailnet reach and the tunnel never carries. One Bun process on the offsite cluster serves the whole zone and that name. It is a peer of Architecture/Spindrift, not a part of it: it shares the gateway that fronts it and nothing else.
oauth2.lolwtf.dev is oauth2-proxy's authenticated edge: its own oauth2-tls listener on offsite's cluster-gateway, a cert-manager DNS-01 certificate, and an unproxied A record external-dns writes from the route at the gateway's address. A Spindrift App publishes its name the same way β an unproxied A record at the Apps gateway for reach: private, a proxied CNAME at the Spindrift tunnel for reach: public; Architecture/Spindrift covers the reach and auth semantics.
Flux owns platform namespaces and installs Spindrift's control plane, target RBAC, policy engine, shared authentication, and edge workload. Spindrift then reconciles only its delegated App namespace and resources inside pre-provisioned vessels. This controller boundary is desired state too; Architecture/Spindrift names both sides.
Use this to stand up a Spindrift installation from nothing: the Terraform that provisions what it federates into, the Flux-delivered chart consumer, the first-operator passkey enrolment, and connecting Targets. What Spindrift is and where its ownership boundary sits is Architecture/Spindrift β this page only installs it. No installation is declared under clusters/ today; the full set of cluster declarations a running installation carries is in git history, and reverting the clusters-teardown PR restores every one of them at once β the declaration section below states the shape so this page stands on its own either way.
An unmet prerequisite row carries the Terraform that clears it and the root it belongs in; Spindrift opens the stanza as a pull request on this repository and Atlantis applies it. The boundary β Spindrift proposes, Terraform owns β is stated on Architecture/Spindrift.
Architecture/Spindrift β the deploy control plane and its explicit runtime ownership boundary
One set is not authored in git: a Cluster in spindrift-datastores is a Datastore Spindrift provisioned through the cluster API, and the row in its database is the desired state. It lives in a namespace of its own because a Datastore outlives every App attached to it. Inspect it like any other; change it through the product. See Architecture/Spindrift.
| Directory | What it is |
|---|---|
atlantis | The Terraform apply path for every PR β see Architecture/GitOps. |
dave.yaml | An ai-agent chart release in the agents-sandbox namespace. |
descheduler | Cluster-local descheduler release. |
hub | apps/hub, deployed through the first-party packages/charts/app chart. |
prowler | Cloud security posture scanning, via packages/charts/prowler. |
spindrift | The Spindrift control plane itself β Architecture/Spindrift. |
spindrift-target | Pulled straight from clusters/base/platform/spindrift-target: namespace, RBAC, and network policy for Spindrift-owned workloads. |
GitHub Actions jobs that need a real machine boundary run in skiffs β ephemeral cloud-hypervisor microVMs, one job each, destroyed afterwards. apps/bosun keeps a pool of them warm. It is a peer of Architecture/Spindrift, not part of it.
services.bosun.spindrift turns a host into a second, independent work source alongside its GitHub warm pool: bosun long-polls Architecture/Spindrift's outbox instead of waiting for GitHub to hand a registered skiff a job.
This is one of Spindrift's own build routes β see Architecture/Spindrift for the other three and how rank among them is decided.
bosun mints its own JIT configs and skiff registrations as an installation of the shared Spindrift+bosun GitHub App β see Architecture/Spindrift. services.bosun.github.appId and .privateKeyFile (apps/bosun/module.nix) name the App id and bosun's own private key; apps/bosun/github.go signs the JWT and narrows every mint to administration:write, resolving the installation per repository.