Credentials on this page are redacted. This is the public export; the real values live in docs/ACCESS.md in the private repository.
Changing things
I want to change X: the task-shaped guide — WORKING-ON-IT.md
Working on it — how to change each moving part
Access and credentials are in ACCESS.md. This is the "I want to change X, what do I do" guide.
The demo is deliberately split so that the platform and the applications change independently:
apps/* application teams own this
platform/openchoreo-config platform team owns this (component types, traits,
resource types, workflows)
platform/dagger-modules platform team owns this (the CI pipeline itself)
platform/bootstrap the install; rarely changes
Change an application and see it deploy
The full loop is webhook-driven — push and walk away.
cd apps/inkwell
$EDITOR rails/app/controllers/notes_controller.rb
git commit -am "change something"
git push
Then watch it:
export KUBECONFIG=~/.kube/openchoreo.yaml
CP="kubectl --context k3d-openchoreo-cp"
WP="kubectl --context k3d-openchoreo-wp"
$CP get workflowrun --sort-by=.metadata.creationTimestamp | tail -3
$WP get workflows.argoproj.io -n workflows-default --sort-by=.metadata.creationTimestamp | tail -3
# follow the build
POD=$($WP get pods -n workflows-default --no-headers | awk '/build-and-publish/{print $1}' | tail -1)
$WP logs -n workflows-default "$POD" -c main -f
The pipeline runs rubocop, bundler-audit, the RSpec suite twice (against a Postgres with pgvector/TimescaleDB and against one without), then builds and publishes. A lint or test failure stops the build before publishing — that is the point.
Only the components whose appPath was touched rebuild, and OpenChoreo
starts one WorkflowRun per matching Component with the image name derived
from the component. Left alone, that means components sharing a Dockerfile each
run the whole pipeline and publish their own copy of an identical image: three
for Inkwell (web, worker, nightly), five for the Zulip bots.
When several components ARE one image, say so with imageName and
siblingWorkloads. One component owns the build; the rest set
autoBuild: false:
# inkwell-web -- the owner
workloadPath: "/deploy/web"
imageName: "rails" # default-inkwell-rails, not -inkwell-web
siblingWorkloads:
- {component: inkwell-worker, workloadPath: "/deploy/worker"}
- {component: inkwell-nightly, workloadPath: "/deploy/nightly"}
- {component: inkwell-worker-profiled, workloadPath: "/deploy/inkwell-worker-profiled"}
The one build publishes one image and then generates a Workload per component
from its own workloadPath, in parallel. Each still gets its own endpoints,
config and start command — the command belongs to the ComponentType, which is
the only thing that actually differs between a Rails web and worker.
buildPath remains for the other case: a narrow appPath (a single file
matches) with the build rooted elsewhere. Prefer siblingWorkloads, because
narrowing has a sharp edge — a change to code the components share matches no
narrow appPath and rebuilds nothing, silently. Note also that
appPath: "/" matches nothing at all; the spelling for "the whole repository"
is the empty string. See docs/FINDINGS.md.
Run the pipeline locally instead
Same code, same engine, no push:
kubectl --context k3d-openchoreo-wp port-forward \
-n openchoreo-workflow-plane svc/dagger-engine 18080:8080 &
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:18080
cd platform/dagger-modules
dagger call ci --source=../../apps/inkwell/rails \
--registry=host.k3d.internal:10082 --image=inkwell --tag=dev
# or just one step
cd ruby && dagger call lint --source=../../../apps/inkwell/rails
cd ruby && dagger call test --source=../../../apps/inkwell/rails --with-capabilities
Change the console
The console (apps/console) is the platform's own web application: projects,
environments, deploys, builds and the controls for them. It is an OpenChoreo
component like any other, so the loop is the ordinary one.
cd apps/console
$EDITOR app/model.py # the joins -- what a page MEANS
$EDITOR app/sources/ocapi.py # what it reads
$EDITOR app/templates/ # what it looks like
git commit -am "..." && git push
Tests need no cluster and no stores. Lint, format and the lockfile must run inside the uv container — the host cannot resolve the internal package index:
docker run --rm --add-host=host.k3d.internal:host-gateway -v "$PWD":/w -w /w \
ghcr.io/astral-sh/uv:0.5.29-python3.12-bookworm-slim \
sh -c 'uv run --group dev ruff format . && uv run --group dev ruff check . \
&& uv run --group dev pytest -q'
To drive it against the live platform from this machine, host.k3d.internal
has to resolve: the LGTM routes are bound to that hostname, so reaching the
same port as localhost gets a 404 from the gateway that looks exactly like an
empty store. apps/console/README.md has the full command.
Adding a page. Every page has a JSON twin under /api returning the same
model, and every upstream call goes through sources/_http.soft so a panel that
could not load renders as a notice naming the upstream, never as an empty panel.
Keep both: they are the two habits that make the surface trustworthy.
Publishing it. The console is NOT published by the public-hostname trait.
That trait mints a name under the project's zone, which resolves through the
*.jung.town wildcard A record — LAN only. Shared surfaces get a specific
proxied CNAME in platform/openchoreo-config/public/dns-records.yaml plus a
route in app-routes.yaml. If a thing is meant to be reachable from outside
this house, it goes in those two files or it does not.
Change the CI pipeline
The pipeline lives in platform/dagger-modules/ and is consumed by the build
step over git, so changes take effect on the next build with no cluster
change.
cd platform/dagger-modules
$EDITOR ruby/main.go # e.g. add a step
dagger develop # regenerate bindings
dagger functions # confirm it compiles
git commit -am "add SBOM step" && git push
The build step clones this repo at the ref pinned in the rails-builder
ClusterWorkflow (module-ref parameter, currently #main) and prints the
resolved commit, so every build log records exactly which pipeline version ran.
To pin a released version instead of tracking main, tag the module repo and
change module-ref to …#v1.2.0.
Change the shape of the pipeline (which steps, in what order)
That is the ClusterWorkflow:
$EDITOR platform/openchoreo-config/workflows/rails-builder.yaml
kubectl --context k3d-openchoreo-cp apply -f platform/openchoreo-config/workflows/rails-builder.yaml
The Argo step it calls is a ClusterWorkflowTemplate and lives on the
workflow plane, not the control plane — a common mix-up:
kubectl --context k3d-openchoreo-wp apply -f platform/openchoreo-config/workflows/dagger-build-template.yaml
Keep the build's trace whole
A new step has to go through stage() and take buildRun, or it is invisible
to every dashboard; the root span, the derived trace id and the log export are
the Argo step's job. platform/dagger-modules/README.md ("The telemetry
contract") is the checklist, and docs/OBSERVABILITY.md ("Tracing the build")
shows what a correct build looks like in Tempo and Loki.
Change the golden path (component types and traits)
Component types are generated from OpenChoreo's shipped ones so that upstream's ~270 lines of routing logic never drift:
$EDITOR platform/openchoreo-config/component-types/generate.sh
./platform/openchoreo-config/component-types/generate.sh # regenerate
git diff platform/openchoreo-config/component-types/ # review
kubectl --context k3d-openchoreo-cp apply -f platform/openchoreo-config/component-types/ruby-service-v1.yaml
Never hand-edit ruby-*.yaml — the generator overwrites them.
Traits are hand-written and applied directly:
kubectl --context k3d-openchoreo-cp apply -f platform/openchoreo-config/traits/otel-instrumentation-v1.yaml
Changing a component type or trait cuts a new ComponentRelease for every component using it, and only the root environment adopts it. Measured, not assumed: applying nine regenerated types on 2026-09-06 created 23 releases within three seconds, staging and development repointed at them through autoDeploy, and every production binding stayed exactly where it was.
That is because a ComponentRelease freezes the ComponentType's whole spec,
not a reference to it — spec.componentType carries {kind, name, spec}, and
the render uses the frozen copy. So an environment renders with the type as of
the release it is pinned to, and the sentence that used to sit here — "re-renders
every component using it" — was true of staging and false of production.
The practical consequence, and it is easy to lose an hour to: a new field on a ComponentType is inert in production until production is promoted onto a release cut after the edit. The field is accepted on the binding (pruning happens at render time, not admission), so it sits there looking applied and changes nothing.
kubectl --context k3d-openchoreo-cp get releasebinding inkwell-web-development \
-o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
# Which release an environment is on, and whether that release knows the field:
kubectl --context k3d-openchoreo-cp get releasebinding inkwell-worker-production \
-o jsonpath='{.spec.releaseName}{"\n"}'
Two things that will bite you
- ComponentType validations cannot see a Component's own labels. The
template context has no
component.*scope;metadata.labelsis OpenChoreo's generated labels. Rules about submitted metadata belong inplatform/openchoreo-config/policies/as admission policies. - The container
envis a single CEL expression returninglist(EnvVarEntry), and CEL will not concatenate a map literal onto it. To inject an env var, write a trait — traits patch the rendered resource after CEL runs. Seetraits/rails-runtime-secrets.yaml.
Add a backing service (resource type)
Resource types are the self-service catalog. postgres is the worked example:
$EDITOR platform/openchoreo-config/resource-types/postgres.yaml
kubectl --context k3d-openchoreo-cp apply -f platform/openchoreo-config/resource-types/postgres.yaml
A developer then asks for one in a few lines:
kind: Resource
spec:
type: {kind: ClusterResourceType, name: postgres}
parameters:
database: inkwell
extensions: [vector, timescaledb]
If your resource type emits a third-party CRD, grant the data-plane agent
RBAC for it — the agent is only permitted the API groups OpenChoreo ships
with. Without the grant you get Synced=True and OutputsResolved=True while
nothing is created; only ResourcesReady=False reveals it. See
resource-types/cnpg-agent-rbac.yaml for the pattern.
Deploy and promote
occ component deploy inkwell-web --namespace default --project inkwell # to dev
occ component deploy inkwell-web --namespace default --project inkwell --to staging
occ resource promote inkwell-db --namespace default --env staging
Promotion copies the source environment's immutable ComponentRelease pointer
into the target's ReleaseBinding.spec.releaseName. Nothing is rebuilt.
Resources behave differently: occ resource promote ignores the pipeline and
jumps the target to status.latestRelease.
Roll back
Releases are immutable, so rollback is repointing:
CP="kubectl --context k3d-openchoreo-cp"
$CP get componentrelease | grep inkwell-web # pick an older one
$CP patch releasebinding inkwell-web-development --type merge \
-p '{"spec":{"releaseName":"inkwell-web-6549566b69"}}'
Take something out of service
kubectl --context k3d-openchoreo-cp patch releasebinding inkwell-web-development \
--type merge -p '{"spec":{"state":"Undeploy"}}' # Active | Undeploy
Lock a commit out of an environment
The only gate here a person sets and a person clears. Capacity, soak, the deploy window and the promotion check are all computed — each answers a question about the world, and none of them can be told "not this one".
platform/templates/lock.sh --component inkwell-web \
--commit $(git -C apps/inkwell rev-parse HEAD) \
--to production \
--reason "Leaks a connection under load; found in staging soak"
platform/templates/lock.sh --list
platform/templates/lock.sh --clear --component inkwell-web --commit <sha>
Omit --to and it covers every environment, which is the honest default for
"this commit is bad" — a lock that had to enumerate environments would silently
stop covering the next one somebody adds.
It is enforced in two places and you want both. project-flow refuses with a
409 naming the reason and who set it, which covers every promote and every
rollback — promote.sh, the Zulip bot, the promote label on a merged pull
request and the console button all funnel through one handler. That does not
cover occ component deploy --to, a raw kubectl patch of spec.releaseName,
or a repository pushing a binding through manifest sync. A
ValidatingAdmissionPolicy (openchoreo-config/policies/commit-lock.yaml)
stands in front of those, matching on the ComponentRelease names project-flow
resolved the commit to.
Rollback is refused too, and that is the difference from the capacity check beside it. Capacity exempts rollback because a rollback returns to a release the environment has already run. "Roll back to the known-bad one" is precisely the mistake a lock exists to catch, and it is the one people make when the good release is two back rather than one.
There is no --force. Capacity offers one because its arithmetic is a
projection and a projection can be wrong. A lock is a person's judgement, and a
gate another person can force past is a suggestion with extra steps. Clear it —
and clearing is recorded.
One commit can produce several releases: a manifest edit re-cuts one without
rebuilding anything. --list shows every release a lock resolved to, and locking
chess-render@8baa8723 covered four.
A lock names ONE component, and in a monorepo that is a sharp edge. inkwell
builds inkwell-web, inkwell-worker and inkwell-nightly from one repository,
so a bad commit produces a release for each — and locking inkwell-web@c260ec93
leaves the other two free to promote off the same commit. Found while
photographing the promote page: the lock was on inkwell-web, which was not
moving, while inkwell-worker and inkwell-nightly were moving on exactly that
commit and were offered as normal. Lock each component you mean, and check
--list afterwards. A --all-components that resolved the commit across every
component in the project is the obvious next thing and is not built.
Gitea
http://localhost:3001 platform / REDACTED
| repo | contents |
|---|---|
apps/inkwell |
the Rails app, its Dockerfile, and deploy/*/workload.yaml |
platform-eng/dagger-modules |
the reusable CI functions |
platform-eng/openchoreo-config |
platform CRDs |
The org is platform-eng, not platform, because org names share a namespace
with usernames in Gitea and the admin user is called platform.
Clone with the API token rather than a password:
TOKEN=$(cat infra/.gitea-token)
git clone http://platform:$TOKEN@localhost:3001/apps/inkwell.git
From inside a cluster the host is host.k3d.internal:3001, not localhost.
Webhooks
apps/inkwell has a push webhook to POST /api/v1alpha1/autobuild. To inspect
deliveries: repo → Settings → Webhooks → the hook → Recent Deliveries. To
re-run the wiring:
./infra/setup-webhook.sh apps/inkwell
When something is wrong
CP="kubectl --context k3d-openchoreo-cp"
# the four conditions that actually matter
$CP get releasebinding <name> -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}'
# controller logs
$CP logs -n openchoreo-control-plane -l app.kubernetes.io/component=controller-manager --tail=100
# the build's own logs
kubectl --context k3d-openchoreo-wp logs -n workflows-default <pod> -c main
Known-confusing failure modes are collected in FINDINGS.md. The two that cost the most time:
- Status lags ~5 minutes — OpenChoreo polls rather than watches.
- A connection that stays "pending" forever usually means the provider's
endpoint
portdoes not match what the consumer asked for. Endpoint resolution matches onport, nottargetPort, and reports nothing.
Following a trace across services
The mesh is instrumented so that one browser request produces one waterfall:
inkwell-web POST /notes Server (root)
inkwell-web INSERT inkwell Client
inkwell-worker default process Consumer ← the job, parented not linked
inkwell-worker POST Client
inkwell-enrich POST /embed Server
inkwell-worker POST Client
inkwell-enrich POST /attachments/12 Server
inkwell-enrich S3.PutObject Client ← Garage
inkwell-worker UPDATE inkwell Client
In Grafana: Explore → Tempo, or the OpenChoreo overview dashboard, which links to each component's traces directly. Every component of the mesh lives in one project so the waterfall stays whole.
By hand, against Tempo
Worth knowing because the portal's Traces tab is gone — traces live in Tempo now and Grafana's datasource proxy makes them scriptable:
G=https://grafana.jung.town
A='-u admin:REDACTED'
# recent traces
curl -s $A "$G/api/datasources/proxy/uid/tempo/api/search?limit=10" | python3 -m json.tool
# every span in one trace, in order
curl -s $A "$G/api/datasources/proxy/uid/tempo/api/traces/<traceID>" | python3 -c '
import sys, json
d = json.load(sys.stdin)
for b in d["batches"]:
svc = [a["value"]["stringValue"] for a in b["resource"]["attributes"]
if a["key"] == "service.name"][0]
for ss in b["scopeSpans"]:
for sp in ss["spans"]:
print("%-18s %s" % (svc, sp["name"]))'
In the UI: Explore → Tempo → Search, filter on service.name. The
OpenChoreo overview dashboard links straight there per component.
Service identity is
service.name/service.namespace. Components carrying an OTel SDK set it fromOTEL_SERVICE_NAME; eBPF-only ones get it from Beyla, which is configured to read OpenChoreo's own pod labels so both agree. Without that config Beyla names workloads after the Deployment and the same platform appears under two naming schemes.
If a trace stops at a service boundary
Before suspecting propagation, check the collector. Alloy runs no sampler, but
it does run a drop filter (otelcol.processor.filter "noise" in
platform/bootstrap/values/lgtm-alloy.yaml), and a span that matches it never
arrives. The historical version of this trap — OpenChoreo's shipped collector
dropping spans at 10/sec, per hop — is written up in
FINDINGS.md; the symptoms were indistinguishable from a
propagation bug.
To prove propagation independently of the collector, capture the wire:
# in the caller's pod: what did it actually send?
ruby -rsocket -e 'srv=TCPServer.new("127.0.0.1",9999)
loop { c=srv.accept; while (l=c.gets) && l.strip!=""; warn l; end
c.print "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; c.close }' &
ENRICH_URL=http://127.0.0.1:9999 bin/jobs
A traceparent: 00-<32hex>-<16hex>-01 on the wire means the application is
doing its job and the loss is downstream.
Open a pull request and get an environment
Push a branch and open a PR against apps/inkwell. Within a few minutes a
comment appears with a URL:
Review environment is up: https://review-pr-7-default.jung.town
Environment `review-pr-7` on data plane `default`, built from `my-branch`.
It is destroyed when this PR is closed or merged.
What was created, and why it is that much:
Environment/review-pr-N |
pinned to its data plane at creation — dataPlaneRef is immutable |
ProjectReleaseBinding |
creates the cell namespace, and brings the project's ResourceQuota with it |
ResourceReleaseBinding per Resource |
its own Postgres and object store — connections resolve per environment |
a forked Component per component in the PR's repo |
pinned to the PR branch; branch matching is exact-equality, so a fork is unavoidable |
a ReleaseBinding for the rest of the project |
bound at their staging release, not forked |
Merging or closing the PR destroys all of it. Watch it:
CP="kubectl --context k3d-openchoreo-cp"
$CP logs -n project-flow deploy/project-flow -f
$CP get releasebinding -n default -l project-flow.openchoreo.dev/owned=true
A review environment is a full stack per PR — roughly 3GB for a Rails
project, one pod for a static site — so OC_MAX_REVIEW_ENVS is 2.
Review environments are not in the deployment pipeline. They used to be
spliced into staging's promotion targets (the DAG has no word for "a fork of
staging"), which is why the portal drew every open PR as an arrow out of
staging. Nothing needs them there: OpenChoreo only reads promotionPaths to
find the root for autoDeploy, so the pipeline is staging → production,
always, and a review environment is reachable from its PR comment, the
Environments dashboard and kubectl. A component that should never be forked
(a benchmark rig, a one-off job sharing a repo) opts out:
metadata:
annotations:
project-flow.openchoreo.dev/skip: "true"
Create a new project from a template
./platform/templates/new-project.sh --template rails-service --name orchard
One command: the Gitea repository, the rendered source, the webhook, the
Project, its cell, its database and its components. Templates are
rails-service, static-site, zulip-bot and ruby-gem; --dry-run renders
into a temp directory and stops.
Two things it deliberately leaves to you, both explained in
platform/templates/README.md: the
project's wildcard SAN on gateway-cert.yaml, and the promotion.
rails-service is the interesting one. It produces three components from one
repository through two pipelines — a Rails service and its worker from
/rails via rails-builder, and a documentation site from /docs via
static-builder. A change under /docs rebuilds only the site. It also ships
endpoints that make each kind of slowness recognisable in a profile; see
Diagnosing a slow endpoint.
Promotion
A merge to main lands in staging by itself: staging is the root of the
application pipeline, autoBuild builds the commit and autoDeploy binds the
release. Nothing is rebuilt afterwards; every promotion is a repoint of
spec.releaseName onto the identical content hash.
Production is automatic by default. project-flow reads a policy off the
Project:
metadata:
annotations:
project-flow.openchoreo.dev/promotion: auto # auto (default) | manual
project-flow.openchoreo.dev/soak: 5m # Ready in staging this long first
project-flow.openchoreo.dev/promotion-check: http # GET the staging URL; anything < 500 passes
project-flow.openchoreo.dev/production-window: "Mon-Fri 09:00-17:00 America/Los_Angeles"
Every reconcile (30s) it looks at each auto project: if staging holds a
release production does not, that release has been Ready in staging for the
soak (counted from when it appeared there, not from the Ready condition's
timestamp, which a rolling update never moves), the staging URL answers, and
the window is open, it calls the same promote() the manual paths use, with
promoted-by: auto. Outside the window the promotion is parked with the time
it will go, visible in /state and announced in the project's promotions
topic. A staging that does not answer is announced once and left alone.
And it asks whether the result will fit — which it did not until
2026-09-06, and the asymmetry was the wrong way round. _promote_or_rollback
refused a promotion that would breach the target cell's quota and offered
force; the soak timer promoted whatever was ready and asked nothing. The path
with nobody watching was the unguarded one.
The check is asked once, for the set the pass is about to promote, and that
is the whole reason it lives in auto_promotions() rather than inside
promote(). promote() returns as soon as it has patched the binding and does
not wait for Ready, so everything promoted in one pass surges at the same time.
Measured on chess, whose production cell holds 6Gi with 3840Mi used:
one component needs 768Mi + 307Mi margin vs 2304Mi free fits
all four needs 3072Mi + 307Mi margin vs 2304Mi free does NOT fit
Four per-component checks would each pass and the four together would wedge the
namespace. That is the arithmetic capacity_check exists to get right, and it
only gets it right if it is asked about the set.
All or none, and no force. capacity_check answers about a set — used
already counts what is running and each member is charged its surge on top — so
it cannot say which member is the problem, and picking a subset that fits would
be a decision nobody asked for. There is no force because there is nobody here
to offer it to; instead the refusal is announced once with the arithmetic, each
component backs off for ten minutes, and it retries. A cell that is full because
something else is mid-rollout empties on its own, and that case must not need a
person.
The window binds the person too
The same asymmetry ran the other way for production-window: the soak timer
had always honoured it and /promote had always ignored it, so a project saying
"production changes on weekday afternoons" bound the robot and not the person —
and the person is the one who promotes at 2am. rideshare declares
Mon-Fri 09:00-17:00 America/Los_Angeles, so this was live rather than
theoretical.
$ platform/templates/promote.sh --project rideshare
HTTP 409 {"error": "rideshare promotes to production during
`Mon-Fri 09:00-17:00 America/Los_Angeles` and the window is closed;
it opens Mon 16:00 UTC", ...}
Three deliberate limits, and each is a different answer from the gate beside it:
- Promote only. A rollback is what you do when production is already broken and a schedule must never stand in front of it.
- Production only. The annotation is
production-windowand the soak timer only ever promotes to production, so applying it to a promotion into staging or a review environment would be inventing a policy nobody wrote. force-able, which a commit lock is not. A lock is a judgement about one commit; a window is a schedule, and the 2am incident fix is the case it exists to allow rather than the case it exists to stop.
The console shows it beside the button rather than after it, reading
project-flow's /window — the same helper the 409 comes from, so the sentence
on the page and the sentence in the refusal are the same sentence.
not-Ready needed nothing
It was already on both paths and with the same test: promote() calls
is_ready(src) and the soak timer inlines
condition(src, "Ready").status == "True", which is what is_ready is. Two
spellings of one rule is how they drift, so the self-test pins that they agree —
including on a binding with no conditions at all, where "not ready" and "no
answer" must come out the same.
The one difference is shape, and it is deliberate: capacity and the lock refuse
up front and move nothing, while not-Ready is discovered per component inside
the loop and comes back as retry in a 207. Promoting three of four and saying
so is a real outcome for a condition that clears by itself.
chess, rideshare and the walkthrough's showcase are auto; inkwell
and orchard are manual, which is where the three spellings below apply:
platform/templates/promote.sh --project inkwell # the CLI
@openchoreo promote inkwell # in Zulip
add the `promote` label to a pull request before merging it # in review
All three POST to project-flow's /promote, which does four things,
because doing three of them looks like success and deploys nothing:
1. ProjectReleaseBinding creates the namespace; nothing else applies without it
2. ResourceReleaseBinding one per resource; no CLI can create the first one
3. ReleaseBinding pinned to the exact release staging is running
4. spec.state = Active neither autoDeploy nor `occ` ever writes this
occ component deploy --to production still works and still does only step 3.
Rolling back
platform/templates/promote.sh --project inkwell --component inkwell-web --rollback
Releases are immutable and never collected, so this is a repoint. The release being displaced is recorded in an annotation on the binding at promotion time — release names are content hashes with no ordering, and nothing else remembers what you were on. A rollback moves code only: if the release you are leaving ran a forward-only migration, the older code now runs against a newer schema.
To change any of this, edit platform/project-flow/controller.py and re-run
./platform/project-flow/install.sh — the code is mounted from a ConfigMap, so
there is no image to build.
The repository's openchoreo/ directory is applied on push
Every application repository carries its Project, Components, Resources
and staging bindings next to the code, and a push to main server-side
applies that directory under the gitops field manager (project-flow,
sync_manifests). The commit gets a status, openchoreo/manifests, saying
what was created, updated or skipped, and the push's span carries the same
line as an event.
What a repository may not do: bind a release. ReleaseBinding is never
synced, and ProjectReleaseBinding/ResourceReleaseBinding documents are
applied only for the root environment (or development for platform
services). Production cells come from a promotion. Re-run a sync by hand:
SECRET=$(cat infra/.webhook-secret); BODY='{"repo":"apps/inkwell","ref":"main"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -H 'Host: host.k3d.internal' -H "X-Signature-256: sha256=$SIG" \
-H 'Content-Type: application/json' -d "$BODY" http://localhost:8080/sync
new-project.sh still applies the manifests itself on day one (the first
push happens before the hook exists); from the second push on, the repo owns
them.
The walkthroughs
@walkthrough static, @walkthrough rails and @walkthrough teardown in
Zulip run the platform end to end and narrate every step into
#walkthroughs — what was done, the log lines pulled for exactly that step,
and the links. See WALKTHROUGH.md.
The build page
https://console.jung.town/builds/<run> is one page per build: the push that
caused it, the waterfall, each stage's log expanded on load, the tests and their
flame graphs, what the build cached and where its CPU went. It is apps/console.
Every build announcement links to it.
apps/buildviewer used to serve a subset of this at
builds.devtools.jung.town and is deleted. Every route it had has a console
equivalent at the same path shape, the console's is a strict superset, and its
hostname was a LAN A record -- so its links worked for the platform and for
nobody else.
Wiring a repo up:
./platform/project-flow/setup-hook.sh apps/inkwell
That is a second webhook, deliberately separate from the autobuild one.
Never point pull-request deliveries at /api/v1alpha1/autobuild: it picks its
provider from which signature header is present, never reads the event type,
and a pull_request payload parses into an event with an empty branch that
triggers a build for every component in the repo.
Publishing a gem to the internal registry
cd platform/dagger-modules
export GITEA_PACKAGE_TOKEN=$(cat ../../infra/.gitea-package-token)
dagger call -m ./gem-publish check --source=../gems/platform_capabilities
dagger call -m ./gem-publish publish --source=../gems/platform_capabilities \
--registry=http://host.k3d.internal:3001/api/packages/platform-eng/rubygems \
--token=env://GITEA_PACKAGE_TOKEN
Gitea does not enforce version immutability — re-pushing a version overwrites
it and reports success — so the module checks first and refuses. Bump the
version in lib/platform_capabilities/version.rb; --allow-overwrite exists
but should be rare enough to explain in a commit message.
Consumers pin the registry as a scoped source so only internal gems come from inside:
source "http://host.k3d.internal:3001/api/packages/platform-eng/rubygems" do
gem "platform_capabilities", "~> 0.1"
end
The gem-publish module is written with the Python Dagger SDK while
ruby, python, frontend and platform use Go — SDK choice is per-module
and invisible to callers.
Diagnosing a slow endpoint
Every project created from rails-service ships four endpoints that are all
slow and slow for completely different reasons. They exist because a flame
graph only becomes readable once you have seen what each shape looks like.
H='Host: production-orchard.jung.town'
B=http://localhost:19080/orchard-web-http
curl -s -H "$H" "$B/diagnostics/cpu?rounds=400000" # busy
curl -s -H "$H" "$B/diagnostics/memory?rows=30000" # allocating
curl -s -H "$H" "$B/diagnostics/waiting?ms=1500" # idle, and slow anyway
curl -s -H "$H" "$B/widgets" # 51 queries
curl -s -H "$H" "$B/widgets/fast" # the same answer, 2 queries
| symptom | where the evidence is | where it is not |
|---|---|---|
| CPU-bound | Pyroscope process_cpu: one deep frame, most of the samples |
— |
| allocation-heavy | container memory, and objects_allocated in the response |
the CPU flame graph, which is nearly empty |
| waiting | the trace waterfall | both profiles, which are flat |
| N+1 | the trace: a wall of SELECT "parts".* under one server span |
a profile, which barely notices |
The middle two are the ones worth internalising. Slow and busy are
different claims and a profiler answers the second; the Ruby push SDK reports
process_cpu, so an allocation problem shows a nearly empty flame graph and
"profiling says it is fine" is the wrong conclusion.
Getting to the trace for a request you just made
curl -si -H "$H" "$B/widgets" | grep -i trace
# X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
# traceresponse: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Paste the id into Tempo. This is the cheapest observability affordance on the platform and almost nothing had it: without it, "the request I just made was slow" and "this trace was slow" are two facts nobody can join, and you are left searching by service and time window and hoping.
ApplicationController#expose_trace_context sets it, and every bot appends the
same link to its replies.
The leak, and showing a fix rather than asserting one
RollupJob keeps an unbounded cache, so the worker's resident memory climbs for
as long as it runs. One profile says nothing about that; two profiles an hour
apart say everything, which is what Grafana's profile diff is for.
kubectl --context k3d-openchoreo-cp patch releasebinding orchard-worker-production \
--type merge -p '{"spec":{"state":"Active"}}'
# then set ROLLUP_CACHE_BOUNDED=true in deploy/orchard-worker/workload.yaml,
# push, and watch the same graph go flat.
Asking the platform from chat
@openchoreo status inkwell what is deployed where, and what built it
@openchoreo where inkwell-web every environment holding this component
@openchoreo promote inkwell staging -> production
@openchoreo rollback inkwell inkwell-web
@openchoreo builds what the controller is waiting on
status and where read project-flow's /delivery — the same join the
Delivery and Environments dashboards use, so the bot cannot disagree with them.
promote and rollback POST to the same endpoint the CLI and the PR label use.
where volunteers the thing nobody quite asks: if a component's environments
are on different commits, it says so.
The bots talk to each other, and the chat can be reset
#bot-arena is where demo-conductor (a component of the chatops project,
apps/zulip-bots/bots/conductor.py) plays a chess game, writes and finds a
note, checks the weather and asks for a status every ten minutes. Each
conversation is one trace, and its closing message links it. It is the traffic
generator for the bots and for chess-engine, which the HTTP traffic generator
cannot reach.
To start over -- every message gone, the streams rebuilt, the welcome posted
into #welcome, users and bot keys untouched:
export KUBECONFIG=~/.kube/openchoreo.yaml
./platform/zulip-realm/reset.sh
The welcome text is platform/zulip-realm/welcome.md.
Where platform activity is announced
A Zulip stream per project, a topic per kind of activity — builds,
promotions, pull requests. A failed build's announcement names the stage
that failed, links the console's build page already narrowed to that stage, and shows the
last forty lines that stage wrote (read from Loki by ci_stage). Anything that is not production is announced into
#platform-sandbox instead, because a review environment posting on every push
is how a useful channel becomes a muted one.
Announcing is best-effort and never blocks the work: a promotion that failed
because chat was down would be a much worse trade than a promotion nobody was
told about. Configure it with infra/.zulip-announcer (email, api key, site —
one per line); with the file absent, announcements are skipped and everything
else is unchanged.