Two environments

Staging beside production, as it happened ENVIRONMENTS-WALKTHROUGH.md

One project, two environments

What actually differs between staging and production, how the platform lets you say so, and what the console can and cannot show you about it.

docs/VERSIONING-WALKTHROUGH.md is the same shape for a construct changing version. This one is about the other axis: the same release, deployed twice, configured differently.

The substrate had to be built first

The honest starting position was that this could not be demonstrated at all.

inkwell is the project with the most per-environment configuration, and its entire staging side is state: Undeploy — hibernated to save host memory on a five-cluster laptop. An undeployed binding renders nothing, so there is no manifest on one side to compare against, and the compare page said so.

Of the projects live in both environments — chess and showcase — none declared anything per environment. The compare page reported two environments rendering identically, which was true, and useless.

So apps/chess/openchoreo/bindings-production.yaml now declares three differences, and they are three deliberately different classes of knob:

knob where it lives what it does
replicas: 2 componentTypeEnvironmentConfigs chess-engine runs two in production
profiling: {enabled: false} traitEnvironmentConfigs chess-render stops profiling in production
retainPolicy: Retain on the ResourceReleaseBinding the bot's Zulip identity outlives its binding

Each is a decision, not decoration. Two replicas because a rollout with one has nowhere to send traffic while the new pod starts. Profiling off because chess-engine's readiness probe runs a real depth-1 search and opens a span per legal move. Retain because the Zulip identity is an account on a real chat server and deleting a binding should not orphan every message it posted.

Staging declares nothing throughout, which is what makes the difference visible as a difference — and is the platform's normal state: 33 of 34 bindings declare nothing at all.

What it looks like

The two environments compared

The counts across the top are the whole idea:

2 substantive differences   2 declared on purpose   61 environment-scoped   24 identifiers

61 and 24 are the reason this page exists. Two renderings of the same component differ in eighty-odd fields, and almost all of it is the environment name, the environment UID, a namespace and a content hash — noise that a text diff would report and a person would learn to scroll past. Classifying it is what leaves four rows worth reading.

spec.replicas 1 → 2 carries a declared chip, and the Deployment above it says 1 declared. PYROSCOPE_ENABLED true → false carries another. The two substantive rows are the public hostname dropping its suffix — board-staging.chess.jung.town → board.chess.jung.town — which is the public-hostname trait keying off isProduction, and is exactly the kind of thing a reader should look at rather than scroll past.

?basis=live asks the other question — what is running, rather than what was rendered — and the page says which it drew, because the two can legitimately disagree (screenshot).

The classifier was wrong, and wrong in the direction that hides things

The first capture of this page read 4 substantive, 0 declared — about a project that had just written both differences down.

Two bugs, and both failed toward "look at this" for something somebody had already decided:

Both are fixed, with the narrow rule kept narrow: where both sides declare things the stricter both-sides test still governs, and the residual over-claim — an unrelated row that happens to move to the same value — is written down rather than papered over.

The other pages

what one environment holds its components, its backing services, what each costs
one deployment's objects what it is made of, what the cluster said, what it is writing
a backing service its pins, its conditions, its outputs — and Retain policy: Retain
every object in a project one environment at a time, each drilling to manifest, events and logs
release history what the platform decided between one release and the next
a rollback where each component returns to, and the two things a rollback does not do

Adding a trait

There is no "add a trait to a running component and preview it" page. app/compare.py's header names it as the third thing the module is shaped for and says plainly that it is not built.

What the platform does offer is the same question asked before anything exists — the scaffolding preview, where every input is a GET parameter, so the preview is a URL somebody can paste into a review.

the template, untouched "Nothing customised yet"
two traits added 0 added · 3 changed · 4 untouched, field by field

Adding slo-alerts and pprof-scrape changes all three components the template creates, and the diff names each field: traits[slo-alerts].name — slo-alerts added. Removing a trait has no preview at all; it is an edit to a manifest and the change shows up as an ordinary release diff afterwards.

The trait selector on that page listed object names until this was written — otel-instrumentation-v1 and otel-instrumentation-v2, as though they were two traits a component could have both of. It cannot: traits are keyed by logical name and fetchAllTraits says so. It offers logical names now, once each.

Adding a component

chess-opening is a real fourth service — an opening book, which answers the first ten moves from a table so the engine is not asked to rediscover them, and which exists here because it is the flat profile next to a search whose shape changes with its input. It was written, built and autoDeployed like any other component.

The gap

0 on different releases · 1 only in staging · 0 only in production · 4 identical · 1 uncompared

chess-opening only in staging — not promoted to production 47407a37 → —

The page is careful with the wording: "staging is the source and production the target, so 'only in staging' reads as not promoted yet rather than as missing." Those are different facts and only one of them is a problem.

The render diff does something better than refuse. It projects what production would get and says so in as many words:

PROJECTION — not a rendering. The control plane renders with CEL against the live environment, and there is no dry-run on this platform. This is derived from what staging actually rendered, with the production namespace, environment name and environment-uid substituted in. Anything a ComponentType or trait decides conditionally on the environment cannot be seen from here, and nothing below will hint that it is missing.

It even names the field it cannot know — a ConfigMap called chess-opening-production-env-configs-<hash>, because the hash is of a value that does not exist yet.

The gap is two minutes wide, and these screenshots do not re-capture

chess carries promotion: auto with soak: 2m. chess-opening went Ready in staging at 02:53:58 and project-flow auto-promoted it at 02:56:01. The four screenshots above are a record of that window; re-running the shot list now produces four different pages, all of them correct, because the component is in both environments.

That is worth knowing rather than working around: on this platform the never-promoted state is a transient, not a condition. Seeing it again means a project whose promotion is manual, or catching the soak.

A promotion refused on capacity — which I did not have to manufacture

I said in the previous pass that this could not be photographed without inventing a quota. Adding a fourth component to chess made it real:

Will not fit

Will not fit. Needs 3840Mi of 3072Mi free in dp-default-chess-production-a386b579
(quota 6144Mi, used 3072Mi).

  chess-bot      1 x 768Mi steady, +768Mi while rolling (maxSurge 1)
  chess-engine   1 x 768Mi steady, +768Mi while rolling (maxSurge 1)
  chess-opening  1 x 768Mi steady, +768Mi while rolling (maxSurge 1)  [new in this environment]
  chess-render   1 x 768Mi steady, +768Mi while rolling (maxSurge 1)

  Short by 1075Mi including a 307Mi margin. maxSurge is not optional at
  replicas 1 -- maxUnavailable rounds down to 0, so the new pod must exist
  before the old one goes.

The same page shows chess-opening as new in production, replacing nothing.

And it is a projection about promoting the WHOLE project at once, which is what that button does. The auto-promotion that actually happened moved one component and fit — production settled at 3840Mi of 6Gi with all five pods running. The refusal is not wrong; it is answering a different question, and it says which ("This reads a quota snapshot and cannot see a deploy that lands in the next second").

A finding, from reading why the promotion succeeded anyway. capacity_check runs in _promote_or_rollback — the HTTP handler — and auto_promotions() calls promote() directly. So capacity is checked when a person promotes and not when the soak timer does. That is the same asymmetry the commit lock was written to avoid, and it is the wrong way round: the unattended path is the one nobody is watching. Fixing it belongs in auto_promotions() rather than in promote(), because the handler asks once for the whole set and asking per-component would under-count by N-1 — which the handler's own comment says.

What is still not shown

Removing a trait, or removing a component. Neither has a preview: removal is an edit to a manifest, and what it produces is an ordinary release diff afterwards rather than a page of its own.

A refused promotion for a source that is not Ready. Implemented on both paths, and it needs the platform to be in a state it is not currently in.

A closed deploy window is now shown — see the rideshare promote page. rideshare is the one project here that declares production-window, so capture it outside Mon-Fri 09:00-17:00 America/Los_Angeles or the page correctly shows an ordinary button.

Reproducing this

platform/dev/console-local.sh                                    # capture + serve on :18000
platform/dev/shoot.py platform/dev/shots-environments.txt  docs/screenshots/
platform/dev/shoot.py platform/dev/shots-customising.txt   docs/screenshots/
platform/dev/shoot.py platform/dev/shots-adding.txt        docs/screenshots/   # see the note in it

shots-adding.txt is the exception: it photographed a two-minute window and says so at the top. The other two re-capture.

The capture step now also assembles the k8sresources tree — the endpoint that makes the render diff possible — from the RenderedRelease plus the live data-plane objects. Without it the whole rendered half of the compare page 404s, which reads as the platform being broken rather than as the fake being partial. It synthesises no health: the real endpoint computes one per node and the snapshot cannot, so it omits the key rather than reporting everything Healthy and blinding the page that exists to show trouble.