The in-cluster services of the estate's delivery path
(JorisJonkers-dev/deploy-kit#195), in
Go, and the delivery project that deploys them. Generated from
template-go.
| Service | State |
|---|---|
| Release Gate: answers Flagger's webhooks and fails closed | answers all three, starts and undoes migrations, and logs held releases, see The Release Gate; not deployed yet |
| ClusterState Collector: commits the snapshot to the Estate repository | built and tested; not deployed yet, see The Collector |
| Reporter: keeps every Rollout's GitHub Deployment current, stage by stage | built and tested; not deployed yet, see The Reporter. Writes no warmup stage until the Release Gate warms up |
| Vault policy job: applies the rendered policies and roles | built and tested against a dev Vault, see The Vault policy job; not deployed yet |
Catalog method: postgres-catalog, the image the Platform document's postgres engine names to apply its database catalog |
built and tested against a real Postgres and a dev Vault, see The catalog method; not deployed yet |
Catalog method: garage-catalog, the image the Platform document's s3 engine names to apply its bucket catalog |
built and tested against a real Garage, see The bucket catalog method; not deployed yet |
Backup methods: file-backup, postgres-backup, rabbitmq-backup, the images the Platform document's engines name |
built and tested; each writes one generation into the backup claim and keeps the newest BACKUP_RETAIN. The two network methods dump the peer the render hands them as BACKUP_HOST and BACKUP_PORT, logging in with the credential's keys (PGUSER and PGPASSWORD, RABBITMQ_USERNAME and RABBITMQ_PASSWORD) (deploy-kit#284). A Rollback's one-shot run, handed BACKUP_ROLLBACK, writes rollback-<request>-<generation>, which BACKUP_RETAIN never counts and every run deletes after 7 days. file-backup copies a SQLite database consistently: the database and its write-ahead log are copied together into .sqlite-staging on the backup claim, the log is folded in, and the copy must pass SQLite's integrity check, taken up to three times before the run fails; the archive holds the one-file copy and none of its -wal, -shm or -journal files (deploy-kit#470). Handed BACKUP_OFF_CLUSTER, a scheduled run also copies its generation off the cluster, as the off-cluster copy says |
| Path | What it is |
|---|---|
cmd/release-gate/ |
The Release Gate binary: reads ADDR (default :8080), the METRICS_SCRAPER_* that name who may scrape it, and the cluster it runs in, logs JSON with slog, drains on SIGTERM |
internal/gate/ |
The gate's three answers, the webhooks that carry them, what it does between questions (tend.go), and how it reads the cluster |
internal/server/ |
The HTTP surface: /healthz, /readyz, the service's own routes, graceful shutdown |
cmd/collector/ |
The ClusterState Collector binary: captures at start and on its own interval |
internal/collector/ |
What the Collector captures, the snapshot it writes, and the envtest case that runs it against a cluster |
internal/githubapp/ |
A GitHub App installation: one file of one repository, read and written, and the Deployments of every repository it is installed on |
internal/loop/ |
A pass at start and on an interval, and the freshness probe that says one succeeded lately: the Collector's and the Reporter's |
cmd/reporter/ |
The Reporter binary: a pass at start, on every event Flux posts it, and on its own interval |
internal/reporter/ |
The stage each Rollout is at, read from its unit, its source and its Canaries; the writes to its Deployment; and the envtest case that runs it against a cluster |
cmd/vault-policy/ |
The Vault policy job's binary: runs once and exits |
cmd/postgres-catalog/, internal/catalog/ |
The catalog method's binary, and what it applies: databases, roles, adoption and the Secret Store's database roles |
cmd/garage-catalog/, internal/bucketcatalog/ |
The bucket catalog method's binary, and what it applies through Garage's admin API: each project's key, its buckets and its grants |
cmd/backup/, backup.Dockerfile |
The backup methods' one binary, backup files|postgres|rabbitmq, and the three images built from it, one target each |
internal/backup/ |
Writing one generation atomically, pruning to BACKUP_RETAIN and a Rollback's copies by age, and what each method writes: a gzipped tar of /data with each SQLite database copied consistently, a gzipped pg_dumpall, the broker's definitions |
internal/offcluster/ |
The off-cluster copy: encrypted to an age recipient, written to an S3-compatible bucket with a Signature Version 4 signer of its own, read back, and pruned to BACKUP_RETAIN; garagetest/ starts the one-node Garage its tests and the command's write to |
internal/vaultpolicy/ |
Reading the rendered documents, the six Vault calls, and the run that writes what Vault does not hold as rendered |
internal/deploykit/ |
Go types generated from deploy-kit's published JSON Schemas, one package per schema, and the test that holds them to deploy-kit's corpus |
third_party/deploy-kit/ |
The schemas, their accept/refuse corpus and the spec files the corpus reads, vendored at DEPLOY_KIT_REF; mirrors deploy-kit's spec/v1/ |
scripts/sync-schemas.sh |
Fetches the vendored files from deploy-kit at one release |
deploy/delivery.project.yml |
The delivery project's deploy-kit Project Intent: Flagger, the Release Gate, the Collector and the Reporter, each with the Kubernetes API access it declares |
deploy/env/collector/base.env |
What the Collector's Process receives: the cluster it captures, the repository it commits to, and its GitHub App |
deploy/env/reporter/base.env |
What the Reporter's Process receives: Flux's namespace, and its GitHub App |
Taskfile.yml |
schemas:sync, schemas:check, gen, gen:check, lint, test, build, secrets, and check (everything CI runs) |
The rest (.golangci.yml, the workflows, release-please) is template-go's, unchanged but for
the names; mise.toml adds jq for schemas:sync. The Dockerfile builds one command per image,
chosen with --build-arg APP=.
| Package | Root type | Schema |
|---|---|---|
internal/deploykit/resolved |
ResolvedDeployment |
resolved-deployment.schema.json |
internal/deploykit/lock |
CompositionLock |
composition-lock.schema.json |
internal/deploykit/pins |
PinAnnotations |
pin-annotations.schema.json |
internal/deploykit/clusterstate |
Snapshot |
cluster-state-snapshot.schema.json |
go-jsonschema, pinned as a tool in go.mod,
generates each types_gen.go. Two gates hold the chain:
task schemas:checkfails when the vendored files differ from deploy-kit atDEPLOY_KIT_REF.task gen:checkfails when a vendored schema changed and the types were not regenerated.
A generated type decodes every document the corpus accepts, and refuses on decode a missing required field, a wrong type and a value outside an enum. It does not refuse an unknown field, a union no branch of which matches, or a cross-field rule: a service that must refuse those validates against the schema. The corpus test states this per schema.
To take a newer deploy-kit:
# move DEPLOY_KIT_REF in Taskfile.yml, then
task schemas:sync gen
task checkFlagger asks the gate four questions through each Canary's webhooks
(deploy-kit spec/v1/55-delivery.md#the-release-gate). Each carries the Application, the Process
and the Application revision, and the gate reads the rest from the cluster.
| Webhook | Path | Yes when |
|---|---|---|
confirm-rollout |
POST /may-start |
every step of the release has completed: its migration and its prepare Processes, each a Job named for the revision. An Application that has never run one starts at once |
rollout |
POST /checks |
the member's new copy has a pod, every one is ready, and none has restarted; and the gate's own requests to it stay under the platform's error rate and p95 latency |
confirm-promotion |
POST /may-promote |
every member of the Application has passed its analysis for this revision or did not change in it: the barrier; and, for a native Client, the member that serves its protected path refuses it to a stranger |
rollback |
POST /rollback |
the release can no longer pass: a pod of the member's new copy has restarted three times or more, or another member's Canary carries this revision and has failed, or has a pod that has. Flagger reads the yes as the member failing, and asks only while the member is analysed or waits at the barrier with its new copy ready |
Flagger reads a 2xx as yes and anything else as no, so the status is the answer: 200 yes, 409
not yet, with what it waits on, 400 a payload that asks nothing, 403 a caller that is not
Flagger, 503 a question the gate could not answer. Flagger records a refusal's words on the Canary.
For rollback every no, the 503 among them and a 404 from a gate older than the question,
leaves the release where it is: nothing is rolled back, and the barrier stays shut.
- A migration the inputs name is a step from the start. Where the inputs carry a
migration,may-startwaits for its Job of this revision whether or not that Job is applied yet. - It decides from the cluster. Every answer is read when it is asked: the Application's
<application>-release-gateConfigMap, its Jobs, its members' Canaries and Deployments, the new copy's pods. - The one thing it remembers only ever withholds a yes. Flagger's status carries no revision, so a member read as waiting for promotion may be waiting from the release before. The gate notes the revision each member last asked at, and counts a waiting member only once it has asked at this one. A gate that restarts has no notes, and the barrier opens again as each waiting member asks, which Flagger has it do on every tick.
- It fails closed. A ConfigMap that is missing or does not parse, a Process that is no member
of it, a Canary of another Application, a list it cannot read: each is a
503, and the cause goes to the log, not the reply. - It answers Flagger and nobody else. Flagger sends no credential a webhook could carry, so
the gate goes by where the connection comes from: the address of one of the
flaggerProcess's pods indelivery-system, read from the cluster on each request. Only a pod that holds its address now counts: running, not ending, not on the host's network, and under Flagger's own ServiceAccount. Anyone else gets one403whatever they ask, before anything of the request or the cluster is read. A forwarded-for header is not read. Thedeliveryproject's derived policy says the same at the network: Flagger's edge to the gate is the only one declared for its webhooks. - It serves what it holds, read-only, at
GET /releases. For every gated Application, ordered by namespace and Application: its members and its migration, from the release-gate inputs, and its record (serving,pinned,since,warmup). An Application whose inputs or record do not read says so inunreadableand keeps the rest from failing with it. A thousand Applications at most, withtruncatedset beyond that. This is how the estate dashboard reads both without any ConfigMap access of its own (JorisJonkers-dev/estate-dashboard#15): it declares an edge to the gate'shttpsurface, which its derived policy then admits. Reading it starts and writes nothing, so it checks no caller; and because it checks none, one read answers it for 15 seconds and only one read runs at a time, so asking it often costs the API server, and the client Flagger's webhooks share, nothing more. - It answers only at the revision the asking member's Canary carries. A question about a render that has been replaced, or about a Canary that is not there, is refused, and the two read the same.
- A release's steps are read off its Jobs. The inputs do not name them, so every identity that has ever run a release Job of the Application is a step, and its Job of this revision must be there and complete. A Job that is not applied yet is a step not done, never a step the release lacks. The Job that undoes a migration is no step. The inputs name the migration, so a first migration is a step before its Job exists; a first prepare Process is still the one case this cannot see (JorisJonkers-dev/deploy-kit#240).
- The barrier is read off the cluster, not taken on trust. A member has passed when its Canary waits for promotion or is past it. A member "did not change" when Flagger records it as serving what it last saw and its primary runs what its Deployment holds: the same images, commands, arguments and variables. Flagger's record alone is a tick behind a Deployment that just changed. The member that asks is held to its own record: waiting, or analysed with every iteration counted.
- It sends its own traffic. Flagger switches blue-green without sending the new copy a
user's request, so its own request metrics hold only probes, and a static runtime exports none.
On each iteration the gate therefore requests, at the member's Canary service
(
<process>-canary.<namespace>.svc.cluster.localon the Canary's service port), every path the Canary's webhooks hand it: each Route path and the readiness path, each once, three times over. It needs no metric source and works for every runtime (#12).- A request fails on a
5xxor on no answer within 2 seconds, and on nothing else: a401, a404or a redirect is the member answering, and passes. The gate never follows a redirect. - The iteration fails when the failed share is over the platform's
errorRate, when the p95 of the answered requests is over itslatency, or when no request succeeded: no data is no. Both thresholds come from the release-gate inputs, the same for every Application. - A path is requested only if it is URL path segments, each opened by one
/. The schema guarantees that already; the gate holds API access, so it checks again, and a path that is not one, or a Canary with no service port, is a question it cannot answer. - A member whose pods are not all ready is not requested, and a member the Canary hands no path is held to its pods alone.
- The metric checks a Runtime Profile derives (
error-rate,latencyon a member) do not change this: every member is held to the same requests. Metric-based checks come later.
- A request fails on a
- A native Client proves that it signs people in (
E_CLIENT_OPEN, #86). Where the inputs carryprotectedPath, the gate requests it once more before it lets the barrier open: fromprotectedBy, the member the longest Route of the Client's exposure takes the path to, at that member's Canary service, with no credentials and no redirect followed.- A 2xx holds the release:
E_CLIENT_OPEN: <process> serves <path> to anyone. - Any other answer passes, a
401or a redirect to sign in included. No answer holds it. - A
protectedBymember that did not change in the release is not asked: its image was asked when it last did. - Inputs that name a path but no member, a member that is not one, or a path that is not URL path segments are a question the gate cannot answer.
- A 2xx holds the release:
- It starts no prepare Process yet. deploy-kit renders none, so there is no Job to start.
Once a release has failed, Flagger asks nothing more. So every 15 seconds the gate looks at every
gated Application itself (internal/gate/tend.go): each ConfigMap the render wrote under the name
<application>-release-gate, in whatever namespace.
- It records what an Application serves. When Flagger records every member as settled and
every primary runs what its Deployment holds, the Application serves the revision its Canaries
carry. The gate writes that to
<namespace>.<application>-release-record, a ConfigMap of its own indelivery-system, with the Application's namespace and Flagger's digest of each primary. It keeps the record in its own namespace because anything that can write a ConfigMap beside the Application could otherwise say which revision serves; a record naming another namespace is one it cannot answer from. No rendered object says what serves once a new render is applied, so this is the gate's one piece of state, and it survives a restart. When it first sees a revision applied and not yet serving, it notes that and when. - It warms up a revision once it serves. The first round an Application serves a new
revision, the gate requests every member's paths once more through each public hostname its
Canary hands it (
hostnames: the hosts of itsanonymousandauthenticatedroutes), so the request crosses Cloudflare and Traefik to the new primary and warms the caches nearest the cluster. Flagger asks nothing after promotion, which is why this loop does it (#12).- Each request is
httpson port 443, through no proxy, with no redirect followed. The hostname is resolved as an absolute name, IPv4 only, since the derived internet egress admits no IPv6, and the gate connects only to an address that is public. It decides on the address, not the name: loopback, private, link-local, the tailnet's100.64.0.0/10and the reserved blocks are refused. - A
5xx, or no answer within 10 seconds, stops the warmup. The record keeps what it met inwarmup(revision, andfailedwith the first failing request). A401, a404or a redirect passes. A revision is warmed once and never again, and nothing is rolled back: the Reporter fails the release's Rollout on it. - An Application no public host reaches is recorded as warmed without a request.
- Each request is
- It starts a migration only while its proof holds. The up Job,
<identity>-<tag>, is unsuspended when the record's serving revision is the inputs'testedAgainstand no primary's digest has moved since. A release proven against nothing starts only where nothing is recorded as serving. Otherwise the release is held with the schema untouched. - It undoes a migration only under every condition of the Down. The down Job,
<identity>-down-<tag>, is unsuspended when the release is held, the migration ran, every member's new copy has no pod left, every primary still runs the proven revision, andnonTransactionalis false. A first release has no down. Where a condition fails, or the Down itself failed, nothing is undone and the gate says which. - A release is held when its proof is stale, its migration failed, or Flagger failed a member after the gate first saw the revision. Flagger's status carries no revision, so a failure from before that is the release before's.
- A round it cannot finish for one Application does not stop the next, and that Application is reported as unanswerable.
Each round says in the log, as JSON, every held release (release held: serving, pinned and the
reason, stale-proof, migration-failed or analysis-failed), every migration it leaves
applied (migration not undone: the tag the Down would return to and the condition,
first-release, new-copy-running, primaries-moved, non-transactional or down-failed),
and every Application it could not read.
The same round is served at GET /metrics, in Prometheus's text format, for the alert rules
(#22):
| series | one per | what an alert reads from it |
|---|---|---|
release_gate_tended_timestamp_seconds |
the gate | when the last round finished; 0 before the first |
release_gate_held_since_timestamp_seconds |
held release, by namespace, application, reason, serving, pinned |
when the gate first saw the pinned revision applied and not serving |
release_gate_held_deadline_seconds |
held release, the same labels | the Application's gate deadline: a release held past it is one nothing moves on its own |
release_gate_undo_refused |
migration left applied, by tag and condition |
an urgent alert |
release_gate_unanswerable |
Application the gate could not read | the gate answers no for it |
It names namespaces, Applications and revisions, so it answers the metrics stack's pods alone, by
the same rule as the webhooks answer Flagger's: a running pod off the host's network, of the
namespace, selector and ServiceAccount METRICS_SCRAPER_NAMESPACE, METRICS_SCRAPER_SELECTOR and
METRICS_SCRAPER_SERVICE_ACCOUNT name, read from the cluster on each scrape. The three are set
together or not at all, and with none set every scrape is refused. The metrics stack is still on
the old path, where no render names it, so deploy/env/release-gate/base.env does. The rules
that read the series live beside the platform's other alerts, in fleet-infra's
observability-rules, until the observability Project moves to the estate path.
Its grant is get on Canaries and Deployments, list on pods, list and patch on Jobs, and
get, list, create and update on ConfigMaps. A ClusterRole cannot say "only the record"
or "only suspend", so the grant is wider than what the gate does with it
(#21): it writes one ConfigMap per Application in its own namespace,
named <namespace>.<application>-release-record, and suspend on a Job it starts. It starts only a Job the
render wrote (app.kubernetes.io/managed-by: deploy-kit) under the Application's own migration
identity, <application>-migration. A start names the version of the Job the gate read, so a Job put in its
place since is not the one started. A record is named for the namespace as well as the Application,
so inputs under one Application id elsewhere can neither claim it first nor write over it: they
are tended as their own namespace's, and silence nothing of this one. The api block of its Process in deploy/delivery.project.yml declares the grant, and
deploy-kit renders the ClusterRole, its binding and the token from that.
An irreplaceable volume's class names an off-cluster destination, and its backup copies each
scheduled generation there after writing it to the backup claim
(deploy-kit#470). A Rollback's
copy stays on the claim.
| variable | from | what it is |
|---|---|---|
BACKUP_OFF_CLUSTER |
the render, the class's offCluster.destination |
s3://bucket[/prefix] |
BACKUP_OFF_CLUSTER_PATH |
the render | where under the destination this volume's copies go |
BACKUP_OFF_CLUSTER_RECIPIENT |
the render, from the Platform document | the age public key (age1…) each copy is encrypted to |
AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION |
the credential the operator writes at the destination's Secret Store path | the provider's S3 endpoint and the keys that write to the bucket; the region defaults to us-east-1 |
- The generation is encrypted on the pod to the recipient, into
.off-cluster-stagingon the backup claim, so the provider never holds a readable copy. Only the private key, kept off the cluster, opens it. - It is written in one PUT, path-style, to
<prefix>/<path>/<generation>.age, signed over its SHA-256, which the provider checks. Over 5 GiB is refused: multipart uploads are not built. - It is read back with a HEAD: a size or SHA-256 that differs from what was sent fails the run.
- The copies there are pruned to the newest
BACKUP_RETAINof the method, the same count as the claim's.
A run that cannot finish any step keeps its local generation and fails, so a missing copy is never silent. A restore reads the object with any S3 client and decrypts it with the private key, then unpacks it as a local generation:
age --decrypt --identity backup-key.txt files-20261026T031500Z.tar.gz.age | tar -xzdeploy-kit renders, for a Postgres whose engine names catalog: postgres-catalog, a Job and an
hourly CronJob beside it, handed the catalog as CATALOG_FILE
(deploy-kit spec/v1/16-dependencies.md).
Each run, for each database the catalog names, in order:
- creates the owner and data roles, without login, where they are absent;
- creates the database, owned by the owner role, where it is absent;
- adopts a database another role owns: reassigns that role's objects to the owner role and makes it a member of it, so old logins keep working; a database a superuser owns fails the run;
- reassigns what every adopted role has created since;
- grants the data role read and write on every table and sequence of the owner's schemas, now and by default;
- writes
database/roles/<owner>anddatabase/roles/<data>, whose logins are members of the role and act as it on sign-in.
It signs in to Vault as VAULT_ROLE, reads the admin login at the catalog's admin path, and
never drops anything. A second run changes nothing.
deploy-kit renders, for a Garage whose engine names catalog: garage-catalog, the same Job and
hourly CronJob, handed a bucket catalog: the admin API's host and port, where its token is, and
one key per consuming project with the buckets it may use
(deploy-kit spec/v1/16-dependencies.md).
It signs in to Vault as VAULT_ROLE, reads the admin token, and, for each key, in order:
- reads the key's document,
secret/data/<project>/<process>/s3. Where it holds a key, the store imports it if it lacks it, so a Garage rebuilt from nothing hands out the same keys again; a store holding that id with another secret fails the run. Where there is no document, a key the store already holds under the project's name is written down, or else one is minted, and written asaccess-key-idandsecret-access-key, only where no document is; - creates each of its buckets where it is absent;
- grants the key read and write on each, never ownership.
It never deletes a key, a bucket or a grant. A second run changes nothing.
deploy-kit renders a Vault policy and a Kubernetes auth role per identity that holds a grant, as
two JSON documents named for what Vault holds them by, <namespace>-<identity>, and applies
neither (deploy-kit spec/v1/30-deliverables.md#vault-configuration-is-rendered-not-applied).
This job writes them. It is a Job of the apps-vso-secrets Reconcile Unit, so it runs before
any Application that holds a grant, and the unit hands it the documents as files.
| Variable | What it is |
|---|---|
VAULT_ADDR |
Vault's address |
VAULT_ROLE |
the Kubernetes auth role it logs in as; defaults to policy-admin, a platform fixture |
POLICIES_DIR |
where the documents are mounted; defaults to /policies |
TOKEN_FILE |
its ServiceAccount token; defaults to the pod's own |
- The render is read whole before Vault is touched. A directory holding anything that is not a document the render writes is refused, and nothing is applied: half a render is not one.
- It writes no more than a grant derives. A name has one reading: the Project stands before
the first
-system-and the identity after it. A role must bind exactly that ServiceAccount in exactly that Project's<project>-systemnamespace, to the one policy of that name, with no namespace selector. A policy must grant only on the forms of path the render writes, each with the capabilities that form carries and no other:readon a kv document undersecret/data/orsecret/metadata/,readondatabase/creds/<role>,updateontransit/<sign|verify|encrypt|decrypt>/<key>and ontransit/keys/<key>/rotate. No glob, no+, no template, nosudo. So a document that reached the job by another road than the render cannot bind another identity, or grant on Vault's ownsys/andauth/. A form the render gains is refused until the job's list gains it. - Vault parses what the job checked. The policy written is the job's own encoding of what it read, not the file's bytes, so nothing a second parser would read differently reaches Vault.
- It leaves what is already as rendered alone, and writes the policy before the role, so a role never names a policy that is not there. A role that gained a namespace selector is not as rendered: it is written again, without one.
- It never deletes. What Vault holds under a name of the render's form,
<project>-system-…, in a namespace its documents are in, that the documents no longer name is logged as stale, for a human to remove. Another Project's names are that Project's job's to report, and the platform's own fixtures have no such name and are never reported. - A call that fails stops the run, with exit 1, which stops the Reconcile Unit: no Application that holds a grant starts against policies Vault does not hold.
deploy-kit renders the Job, and the ConfigMap that carries the documents, where the Platform
document names the job in policyJob; both are named by the digest of the documents, so a
changed policy set is a new Job. A grant deploy-kit does not render yet, custody and its
prefix, will need the glob this job refuses today.
The Collector captures when it starts and then once every INTERVAL, and keeps running: Project
Intent has no schedule, so it keeps its own. Each capture lists PersistentVolumes,
PersistentVolumeClaims and the pods deploy-kit renders, and compares what they say with
cluster-state.yml in the Estate repository
(deploy-kit spec/v1/20-resolved-deployment.md):
| fact | read from |
|---|---|
a binding, {claim, node} |
a bound claim's name, and the one node its volume's node affinity names under kubernetes.io/hostname. A volume any node can mount binds its claim to nothing |
a placement, {process, node} |
a pod labelled app.kubernetes.io/managed-by: deploy-kit: its app.kubernetes.io/name and the node it is scheduled to. A pod that has finished is not a placement |
A Rollback takes no backup (deploy-kit#557), so the Collector writes no backups list, and reads
past one an older Collector wrote. Facts are ordered and free of duplicates. When they equal the committed snapshot's, the capture
commits nothing, and capturedAt stays what it was. When they differ, it writes the file
once, through the Collector's GitHub App, replacing exactly the blob it read. A snapshot that
names another cluster, or that deploy-kit's schema would refuse, is never overwritten.
| variable | value |
|---|---|
CLUSTER_NAME |
the cluster, as the snapshot names it |
ESTATE_REPOSITORY |
JorisJonkers-dev/estate |
GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID |
the Collector's App and its installation on the Estate repository |
GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE |
the App's private key, as the Secret Store delivers it: a variable, or a file |
INTERVAL |
how often to capture, a Go duration; ten minutes when it is not set |
ADDR |
where it serves its probes; :8080 when it is not set |
A capture that fails is logged, and the next one still runs. Because a capture commits only when
a fact changed, a Collector that stopped and a cluster that stayed still look the same in the
Estate repository. GET /fresh tells them apart: it answers 200 while a capture succeeded within
twice the interval and 503 after, and it is the Collector's liveness probe, so one that stopped
succeeding is restarted and shows as restarts.
The Collector is an Application of deploy/delivery.project.yml. Its api block declares the
three kinds it lists, cluster-wide, which deploy-kit renders into its ClusterRole once the
Platform document admits it; its GitHub App arrives as variables, from a grant on
secret/data/delivery-system/collector.
Every status of a Rollout after pinned is the Reporter's
(deploy-kit spec/v1/55-delivery.md).
The composition workflow creates the GitHub Deployment when it moves a Project's pin, and the
pin source carries the same Rollout as annotations on the Project's unit. The Reporter reads
that unit, and writes to the Deployment where the stage moved.
A pass runs when the Reporter starts, on every event Flux's Provider posts to POST /flux, and
once every INTERVAL. An event is a prompt to look and never evidence: the route is unsigned, so
the Reporter reads nothing from it, not even the unit it names, and reads every unit again. A
forged event costs one pass and writes nothing; a dropped one delays a status by an interval at
most. Flagger sends the Reporter nothing, so the interval is also how often a Canary's progress
is seen.
| stage | written when the cluster shows |
|---|---|
in_progress: applied by Flux; then each member that is not through, checks n/m, waits for the other members, promoting or waits for Flagger |
the unit's last applied revision is the digest its source is pinned to, and a blue-green member has not finished |
success: <project> <version> applied by Flux and ready |
the release is applied and the unit has no blue-green member |
in_progress: promoted; the Release Gate is warming up <application>, … |
every member is through, and the Release Gate has not yet recorded a warmup of each member's Application at the revision its Canary carries |
success: <project> <version> promoted and warmed up |
every member's Canary is settled and its primary serves what its Deployment holds (promoted in this release, or unchanged by it, the same test the Release Gate's barrier makes), and the gate warmed each of their Applications up at that revision without a 5xx |
failure: <application> promoted, but its warmup failed: <request> answered <status> |
the gate's warmup of the revision met a 5xx or no answer. The gate warms a revision once and rolls nothing back, so this stays the Rollout's last word |
failure: <member> rolled back: <Flagger's message> |
a member's Canary failed after the Deployment was created |
failure: Flux could not apply <project> <version>: <reason>: <message> |
the unit is not Ready on this release for any reason but waiting on another unit. Flux retries, so a success may follow |
A status is written only when it differs from the Deployment's latest, so a Reporter that
restarts writes nothing twice. A Deployment whose latest status is success, inactive (a later
release superseded it) or error takes nothing more. A Deployment is the Rollout's only when its
payload names the unit's Project and version, so a Project released from the same repository as
another never takes the other's status. Each status keeps the run link the workflow gave the
first one, and names the Rollout's URL as the environment URL where it has one.
| variable | value |
|---|---|
GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID |
the Reporter's App, deployments: write and nothing else, and its installation on every repository that releases to the estate |
GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE |
the App's private key, as the Secret Store delivers it: a variable, or a file |
FLUX_NAMESPACE |
where the units are; flux-system when it is not set |
INTERVAL |
how often to look, a Go duration; thirty seconds when it is not set |
ADDR |
where it serves /flux and its probes; :8080 when it is not set |
GATE_URL |
the Release Gate, whose GET /releases says what each warmup met; http://release-gate.delivery-system.svc.cluster.local:8080 when it is not set |
GET /fresh answers 200 while a pass succeeded within twice the interval, and is the
Reporter's liveness probe, as it is the Collector's.
The Reporter is an Application of deploy/delivery.project.yml, beside the Release Gate, which
the Platform document's delivery.reporter requires. Its api block declares its four reads:
Kustomizations listed, each one's OCIRepository, the Canaries the unit's inventory names, and a
settled member's Deployment and primary. It reads the gate's GET /releases once a pass, over an
edge to the gate's http surface, the way the estate dashboard does; a gate that does not answer
leaves every promoted release in progress and fails none. deploy-kit renders that into its ClusterRole once the
Platform document admits it; its GitHub App arrives as variables, from a grant on
secret/data/delivery-system/reporter.
mise install # the pinned toolchain
task check # lint, schemas:check, gen:check, tests with coverage, build, secret scan
docker build --build-arg APP=release-gate -t release-gate .
docker build --build-arg APP=vault-policy -t vault-policy .
docker build --build-arg APP=collector -t collector .
docker build --build-arg APP=reporter -t reporter .
docker build --file backup.Dockerfile --target file-backup -t file-backup .task test fails below 80% statement coverage of hand-written code (COVERAGE_MIN in
Taskfile.yml); generated files and the fake GitHub the tests run against are left out of that
figure. The Collector's and the Reporter's envtest cases start a real API server, which task test fetches once
through setup-envtest.