Integrate VictoriaLogs in milner #62

Merged
tepichord merged 31 commits from feat/60-integrate-victorialogs into main 2026-09-16 13:33:40 -07:00
Owner

Closes #60

Automated PR created by forgejo-done adapter.

Closes #60 Automated PR created by forgejo-done adapter.
Refs: #60
Flux-managed VictoriaLogs server (victoria-logs-single 0.13.9) with:
- OCIRepository pinned to ghcr.io/victoriametrics/helm-charts/victoria-logs-single:0.13.9
- HelmRelease with chartRef + valuesFrom ConfigMap
- Flux-owned PVC (victoria-logs-data, 20Gi, persistent class)
- Tailscale ingress at logs.faun-snapper.ts.net
- Values: server.retentionPeriod 7d, retentionDiskSpaceUsage 18GiB,
  ClusterIP service, nodeSelector node-role: worker, pinned image tags
- Vector subchart disabled (deferred to Talos host-log phase)

Refs: #60
Flux-managed VictoriaLogs collector (victoria-logs-collector 0.3.7) with:
- OCIRepository pinned to ghcr.io/victoriametrics/helm-charts/victoria-logs-collector:0.3.7
- HelmRelease with chartRef + valuesFrom ConfigMap
- Values: vlagent image v1.52.0, remoteWrite to http://victoria-logs:9428,
  tolerations for control-plane, pinned image tags
- Collector chart uses fullnameOverride at top level (not nested under server:)

Refs: #60
Adds namespaces/logging.yaml, victoria-logs, and victoria-logs-collector
to the infrastructure Kustomization so kustomize renders all new resources.

Refs: #60
Both new values.yaml files must pass yamllint; add them to the existing
test_yamllint list following the established naming pattern.

Refs: #60
Documents the logging service approval, query access commands
(port-forward + curl for /select/logsql/query and /select/logsql/tail),
per-app capture table (issue #60 step 8), resize blocker explanation
(libvirt prevent_destroy + Talos v1.11 volume growth), and the OSS
no-auth caveat.

Refs: #60
- Add logs.faun-snapper.ts.net to the network services table
- Update Zot status from 'planned' to 'deployed'
- Update storage tier note to include VictoriaLogs 20 GiB PVC in the
  durable budget (~51 GiB of 100 GiB total)

Refs: #60
Ref: #60
The OCIRegistry tag 0.13.9 does not exist in the ghcr.io registry yet;
the latest published tag is 0.12.3. Also fix yamllint violations:
- 2-space gap before inline comment in victoria-logs/values.yaml
- line-length overflow in victoria-logs-collector/values.yaml
The server chart's own Ingress is `server.ingress`; a top-level `ingress:` (the
zot shape, one level up) is silently dropped by Helm. The chart's default is
already off, so the misplaced key changed nothing and looked harmless — but the
file's own preamble promises every key is read, and a later flip of that key
would have done nothing at all.

Also correct the chart-version note: 0.12.3's appVersion is v1.50.0, not
v1.52.0 (that is 0.13.9, which has no OCI tag yet). The server image is still
pinned to v1.52.0 deliberately; the comment now says so. Drop the duplicated
`fullnameOverride` comment in the collector values and fix the `Tests/` path
casing in the OCIRepository comment.

Refs: #60
The per-namespace example used stream-filter syntax that LogsQL rejects:
inside `{...}` is a Prometheus label selector, so it is
`{kubernetes.pod_namespace="woodpecker"}`, not a `field: "value"` phrase
filter. The endpoint list also called `/insert/native` a read endpoint; it is
the write path the collector targets.

The resize blocker undercounted Woodpecker: the durable claims are its server
(10 GiB) plus its agent (1 GiB), which is what makes the total 51 GiB of 100.

Refs: #60
The status column means "observed working on the live cluster, not merged",
and the note directly below it says Zot's registry has not been reconciled and
its tailnet service has not been approved. Flipping Zot to `deployed`, and
adding VictoriaLogs as `deployed` while its manifests are still on this
feature branch, contradicted that in the same paragraph.

The storage budget parenthetical also summed Vault's dedicated disk into a
figure it labelled "of 100 GiB" and dropped the Woodpecker agent's 1 GiB, so
the terms did not add up to the total. Both are corrected.

Refs: #60
test(gitops): guard victoria-logs values against the chart key shape
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
4fdba5c0f1
Neither VictoriaLogs chart ships a values schema, and Helm ignores unknown
keys without a word, so a key one level off installs a release on defaults
while every offline gate stays green. That was the plan's own pre-mortem risk,
and the misplaced top-level `ingress` above was a live instance of it.

Assert the three nested-key placements the directory depends on, plus the
cross-file names they have to agree with: the server fullnameOverride, the
Ingress backend it names, vlagent's remoteWrite URL, the existingClaim, the
`persistent` class, and the collector chart's top-level fullnameOverride.

Refs: #60
tepichord added this to the MVP project 2026-09-15 14:59:33 -07:00
Author
Owner

I checked the preview for the logs service and there is none, just a preview for oz again. I need the preview service to point to the new victoria logs service.

I checked the preview for the logs service and there is none, just a preview for oz again. I need the preview service to point to the new victoria logs service.
The preview overlay only carried the console build and the base stand-in, so
every preview opened oz no matter what the change set was about. Issue #60's
subject is the log store, and it is the rare infrastructure change with a UI
of its own (VictoriaLogs serves /select/vmui/ with no credential), so the
overlay now runs a preview-sized copy of the server and the Service carrying
milner.dev/preview-service is the store's — the router requires exactly one,
so the label moves off the console's Service rather than joining it.

The copy is hand-written rather than the victoria-logs-single chart: Flux's
kustomize-controller does not inflate helmCharts, and a second HelmRelease
would make the Helm controller part of the preview gate. It pins the same
v1.52.0 the infrastructure values pin, stores into an emptyDir instead of the
20 GiB persistent claim, declares no Ingress, and runs no collector — vlagent
needs hostPath and therefore privileged PSA, which a preview namespace
deliberately does not have.
Three places claimed the router dials the change set's own console Service,
which stopped being true with the store's preview: the data path in §9, the
apps/preview row of the repo map, and the reasoning in the overlay itself.
§12f is new — it says what the preview store is (empty on purpose, no
collector because vlagent needs privileged PSA) and where to look at it.
The preview-image assertion carries the message "the same tag the infrastructure
values pin — a preview that drifts from the deployed version reviews the wrong
server", but compared against a literal. That only fails when the *preview*
moves: bump `server.image.tag` in the infrastructure values, leave the preview
behind, and every gate stays green while L3 reviews a server that is not the one
this change set deploys.

Read `gitops/.../victoria-logs/values.yaml` and compose the reference from
`server.image`, so the assertion fails in either direction. Verified by bumping
the preview tag in isolation: the test now fails, and passes again once the two
agree.
docs(preview): the store preview opens a help page at the root, not the UI
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
bb65f1b7ee
VictoriaLogs serves `/` as a small endpoint help page
(app/victoria-logs/main.go, requestHandler). There is no redirect: the Web UI is
the separate `/select/vmui/` path, which is what OPERATIONS.md §12f already
tells the reader to open.

Two comments in this overlay claimed the opposite — that
`preview.faun-snapper.ts.net` "opens its UI" and that the router "proxies the
store's UI at the root path" — so the routed entry point was documented as
something the v1.52.0 image does not do. Say what it does and where the UI
actually is.
Author
Owner

Navigating to https://preview.faun-snapper.ts.net/select/vmui/? results only in a white screen with no console errors. The following payloads timeout when loading:

Navigating to `https://preview.faun-snapper.ts.net/select/vmui/?` results only in a white screen with no console errors. The following payloads timeout when loading: - https://preview.faun-snapper.ts.net/select/vmui/assets/index-Ds1HKnSd.js - https://preview.faun-snapper.ts.net/select/vmui/assets/vendor-Dedt1GPZ.js
The router stripped `Transfer-Encoding` as hop-by-hop — correctly, it must not
forward its upstream's framing — and then sent the body with no framing of its
own. HTTP/1.1 has no way to read that: with neither `Content-Length` nor
`Transfer-Encoding`, the body ends when the connection closes, and this handler
keeps the connection alive, so the client waits for an end that never arrives.

The visible symptom is the review's white screen. VictoriaLogs wraps every
handler in gzip compression above a 1 KiB threshold; gzhttp deletes
`Content-Length` when it compresses, so the assets (`index-Ds1HKnSd.js`,
`vendor-Dedt1GPZ.js`, both well over the threshold) came back gzip+chunked and
hung, while the sub-kilobyte files kept their length and loaded. `index.html`
itself is over the threshold, which is why a page painted and no script ran.

The fix frames the response in `_proxy` instead of trusting the upstream's:
a chunked upstream is re-chunked, relayed one chunk at a time so a live stream
(`/select/logsql/tail`) arrives as it happens rather than after it ends, and
anything finite is buffered and given an exact `Content-Length`. HEAD and the
bodyless statuses (1xx/204/304) forward untouched; the upstream socket is now
closed in a `finally`, because a relay can be abandoned mid-stream.

This is not VictoriaLogs-specific: any previewed app that compresses or streams
(SvelteKit's adapter-node included) was equally unreadable, and disabling
compression on the preview server would only have hidden it for this one case.

tests/test_preview_router.py gains three loopback tests — chunked re-framing,
close-delimited bodies getting a length, and a two-chunk stream read while the
upstream still holds the second — each of which fails without this change.
The router runs this pinned tag with `imagePullPolicy: IfNotPresent`, so
`router/app.py` changing in the tree does not change what the live
`preview.faun-snapper.ts.net` runs: re-pushing 0.1.0 would not roll the pod
either. Bump the tag to 0.1.1 and spell out the build-and-push it needs, since
the registry push is out of band and must precede this manifest reaching `main`
(as the comment above the image already requires).

Without the bump the review's white screen survives every merge, which is the
one thing the fix must not do.
An upstream that sends both `Content-Length` and `Transfer-Encoding: chunked`
had its length forwarded: `response_headers` drops framing as hop-by-hop and
the streaming branch re-declared only the chunking, so the client received the
forbidden pair (RFC 7230 §3.3.3) — a length and a chunked body that only agree
while every hop resolves them the same way.

The buffered branch already dropped the length before stating its own size, so
this was an asymmetry rather than a missing decision: both framings are the
router's now, and the upstream's length goes in both. `self_framed` names the
condition the two branches share instead of repeating it.

The new loopback test sends both headers and fails without the change: the
client read `Content-Length: 999` beside `Transfer-Encoding: chunked`.
docs(router): build the tag the manifest pins, not the one before it
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
aa2b85e2c9
router.yaml now pins `milner/router:0.1.1`, and its comment carries the
build-and-push that has to precede the bump — while the Dockerfile's own build
example still produced `0.1.0`. Following the Dockerfile therefore pushed a tag
the manifest does not name, and Flux would have waited on 0.1.1 forever; the
two instructions name the same tag again.

Scoped to the router: `oz/Dockerfile` carries the same 0.1.0/0.1.1 drift from
#58, but nothing in this change set moves that tag.
feat(preview): open the front door on whichever app a change set is about
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
c963803e86
`preview.faun-snapper.ts.net` dialled one Service, so a preview of the log
store and a preview of the console were the same page and the only way to tell
them apart was to read the router's configuration. The front door now follows
the change set: each candidate Service declares the paths it previews
(`milner.dev/preview-paths`), L3 publishes the pull request's changed files as
`changed_paths` in the `preview-<n>-values` ConfigMap it already writes, Flux
substitutes them onto every candidate as `milner.dev/preview-changed`, and the
router dials the claimant. `oz` is the marked default and declares no paths, so
it can neither shadow the app a change set is about nor be one side of a
contention.

The rule refuses rather than ranks. Two claimants, or several candidates with
none claiming and no default, is not an ordering problem: the old
narrowest-glob tie-break was a guess dressed as a rule, and it is deleted. The
router answers 503 naming the candidates, and the console is where a human
answers — `milner.dev/preview-app` on the preview's own namespace, rendered as
one submit button per choice so swapping between contenders is one click, with
the current choice inert. The pin survives a later push in the same pull
request, dies with the preview, and is never written by a manifest or by L3.
The rule is one module (`previewlib/selection.py`) with two readers, which is
asserted: neither re-declares an annotation key.

A router change is now exercised by its own pull request. For a change set
touching `router/**` or `previewlib/**`, `build-router` pushes
`preview/router:pr-<n>-<sha>` in addition to main's tag, and
`publish-router-override` names it in `preview-router-tag`, which the layer
reads through `postBuild.substituteFrom`. That has to be `substituteStrategy:
Always` with no inline `substitute`, because an inline entry takes precedence
and no override could ever win; for the same reason the committed default lives
inside the manifest's `${router_image:=...}` placeholder. Rollback is deleting
the ConfigMap, which every teardown path already does, so an override
un-ships itself. The cost is accepted: one router pull request is live at a
time, and while it is open its router serves every preview.

`ci/router-tag-guard.sh` holds the invariant that makes the bump meaningful.
`imagePullPolicy: IfNotPresent` plus a path-filtered build make both halves of a
tag/source mismatch silent — source moved and the kubelet never re-pulls; tag
moved and the build never fires — so a pull request is failed for either.

The console's own image is bumped to 0.1.2 because this change set moves `oz/**`
and `previewlib/**`, and nothing in CI builds `milner/oz:<pin>`: `build-oz` only
ever pushes the disposable `preview/oz:pr-<n>-<sha>` a preview namespace runs.
`oz/Dockerfile`'s build example reads the tag out of the manifest now rather
than naming 0.1.0 — the drift `aa2b85e` fixed for the router and left out of
scope there.

`.dockerignore` is new because the build context is the repo root: a local build
baked `oz/__pycache__/*.pyc` from a test run into the image, and the operator's
`.env` was uploaded to the builder. The patterns carry `**/` since, unlike
`.gitignore`, they match from the context root.

The store keeps `preview.faun-snapper.ts.net` as its preview instead of taking a
hostname of its own: one front door serves whichever app the change set is
about, so a second one would be a second front door for what this rule already
serves, plus another tailnet approval and certificate. Two of the layer's
comments said otherwise and are corrected.
Pipeline #64 failed in `build-router`, three commands away from the cause.
Woodpecker expands `${...}` across a step's commands before the shell runs
them, applying bash's operators against its own, much smaller, set of names —
so a name it does not have is *unset*. The `:=` form therefore resolved to its
default word: the `sed` pattern lost the placeholder, still matched the
manifest's `image:` line, and captured the raw `${router_image:=...}` text where
the pinned tag belongs. The step then rejected the tag it had just read.

The bare `$VAR` form is not expanded, which is what the four load-bearing lines
now use:

  * the pinned-tag `sed` reads `[$]{router_image:=...}` — a literal `$` to sed
    and invisible to Woodpecker, the idiom `l2-dry-run` already used;
  * the digest URL derives repository, tag and repo path with `cut` rather than
    `${IMAGE%:*}`, `${IMAGE##*:}` and `${REPO#*/}`, which would have compared
    the build against `/v2//manifests/` — failing a step because a push worked;
  * `l3-preview`'s no-PR line prints `$BRANCH` rather than `${BRANCH:-?}`.

`tests/test_static.py` gains the rule so the class cannot come back: in a step's
commands a `${...}` may only name a variable Woodpecker provides — a bare name,
or a `:-` default, the one operator that means the same in both interpreters. A
shell-only operator (`#`, `%`, `/`, `:=`, `^`, `,`), a name the step assigns
itself, or a body that is not a name belongs to the shell and must be `$VAR`.
Comments are exempt. Five controls confirm it catches the `:=` form, a parameter
expansion, a bare `${LOCAL}` and a length expansion, and still passes a nested
`:-` fallback on CI variables.

The `test` step also installs git. It ran on `alpine:3.21`, which has none, and
two tests now read the workspace's history: the tag guard against throwaway
repositories, and the store's declared globs against `git ls-files`. They error
rather than skip, which is the intent — the guard's invariant should not be able
to pass by being absent.
fix(ci): write the dollar-brace the way the file already spells it
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
a624ba1719
Pipelines #65 and #66 died with "unable to parse variable name" and ran no steps
at all. Woodpecker resolves `${...}` across a step's commands while *parsing* the
configuration — comments included — and rejects a `${` that no variable name
follows. The offender was the comment added in the previous commit, which quoted
the syntax it warns about; it was the only `${` the config had gained, which a
diff of the `${` set against the config that parsed as #64 shows: that set is now
empty again.

A comment quoting this syntax has to spell it the way the file already does
elsewhere — `[$]{` for a literal, the idiom `l2-dry-run` uses — and
`tests/test_static.py` now carries both rules, each learned from a run:

  * a `${` must be followed by a variable name, in a comment as much as in code
    (a violation fails the configuration before any step exists to report it);
  * an expansion in code must name a variable Woodpecker provides, because a name
    it does not have is unset and gets substituted away before the shell sees it.

`woodpecker-cli lint` cannot gate either rule: it reports the defective file
"Config is valid", since it checks the schema without running the substitution.
So the guard is the gate, and AGENTS.md now says to run it before pushing a
change to `.woodpecker/` — CI cannot report a config it fails to parse.
fix(ci): compare the tag against the base tip, not a merge base
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
7c0a4cbeb5
Pipeline #67 reached `router-tag-guard`, which refused the pull request with
"no merge base with 'origin/main'". The ref was there: Woodpecker's clone plugin
fetches the ref under test with a depth limit, so both tips are present as
islands with no history joining them and `git merge-base` has nothing to find. A
guard that cannot run looks exactly like a guard that found nothing wrong, and it
failed one step after the tag check it had just passed.

What the guard asks — did this change set move the router source and the router
tag together — needs two trees, not their ancestry, so it compares the base
branch's tip with HEAD directly. That is also the stricter answer wherever a tip
and a merge base disagree: a tag the base already carries cannot have been built
from source this branch changed, where a merge base would wave it through.

tests/test_ci_guards.py now exercises the workspace CI actually hands over — a
depth-one `file://` clone, asserted to stay shallow, since a complete clone is
the case that already passed and is precisely what hid this. Both directions are
covered: a shallow workspace agrees when source and tag move together, and still
fails when the source moves alone. Both reproduce #67 against the old guard.
fix(tests): stop the suite inheriting the run it is in
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
0383f6e5f8
The `test` step failed on `test_preview_paths_rejects_a_non_numeric_pull_request`
with `assert '\n' == ''`, and only in CI. The suite runs inside a pipeline step,
where `CI_COMMIT_PULL_REQUEST` holds the pull request under test. The script
under test reads that variable on purpose — but the case in question passed an
empty argument meaning "no pull request", and `${1:-...}` treats an empty
argument as absent, so the case was answered by the pipeline's own number. It
never reached the guard it was written for, and failed on the empty list coming
back instead. Locally nothing is set, which is why it passed there.

That test now names each of the two hand-set inputs where nothing else can speak
for it: the argument alone, and each fallback variable alone. It also covers the
fallback itself and the order of the two variables — the reason they are read at
all, and previously untested.

tests/conftest.py clears `CI_*` and `PREVIEW_PR` for the whole suite, so a test
that spreads `os.environ` into a subprocess cannot mean something different in
CI than it does locally. The failing case above would have been caught by that
alone, and a test added later cannot forget it. Verified as load-bearing: with
the fixture removed, the injected-environment run fails exactly as #68 did.

The script itself is unchanged; its header now says what it prints — one line,
empty when there is no list, and nothing at all when the number is refused —
which is why two tests expect a newline and the refusal case expects no bytes.
A preview is one hostname — `preview.<tailnet>` — and the front door behind it
was the revision of `router/` that reached `main`. A change set that changed
`router/` or `previewlib/` could therefore preview everything except itself: the
code under review decided nothing that the code under review changed, and the
rule it was written against was the one already deployed. The story that has to
hold is harder than "the preview works": a change set that changed the router
runs *its* router, for that pull request, at that same hostname.

So the front door's decision gained a layer. `previewlib.selection.front_door`
answers in one order — the namespace's pin, then the change set's own router,
then the app rule and its refusal — and `router/app.py` is one program in two
roles. Without `ROUTER_NAMESPACE` it is the permanent front door and reads every
namespace. With it set — from the pod's own metadata, by the Downward API — it is
the change set's router: it reads its own namespace, decides only between apps,
and never considers the router layer, which is what stops it dialling the Service
that brought the request to it. The two roles are one combination point, so the
answer the front door gives is the answer the console shows.

That per-pull-request router is a workload in the preview's own namespace, built
and reaped with it (`apps/preview/router.yaml`): the change set's image, its own
ServiceAccount, and a Role over its own namespace's Services. The front door's
ClusterRole would have handed arbitrary pull request code a cluster-wide read and
a namespace patch. Its Service declares it, so two pull requests can be previewed
at once, and the pin is read before this layer — which makes the console's Front
door buttons the bypass when a change set's own router does not come up.

`ROUTER_IMAGE` is decided by asking the registry which per-pull-request tag
exists rather than by re-deriving the path filter: what runs is what was built,
and the fallback is the pinned revision read through one extractor
(`ci/router-image.sh`). Because a step that can write an image could point *every*
preview at its own code, the front door's revision is a literal in a reviewed
manifest and nothing substitutes it into `preview-system` any more;
`ci/router-tag-guard.sh` fails a pull request whose router source and pinned tag
disagree. `build-router` builds only when `router/**` or `previewlib/**` moved,
and the `publish-router-override` step is gone with the ConfigMap it wrote and
the `postBuild` that read it.

L2 could no longer diff the overlay. It authorizes as an apply, and the kinds a
change set's router brings — ServiceAccount, Role, RoleBinding — were granted to
its identity by this same change, which had not merged yet, so `kubectl diff`
died on a Forbidden before it saw anything else. It now asks the server what it
may submit, names what it drops and why, and diffs the rest; if that leaves it
nothing to diff it fails, so a check that cannot run cannot look like one that
found nothing wrong. What it drops is not unchecked: L1 validates those objects
offline, and L3 applies them for real through Flux with `wait: true`.

The hop the design rests on is pinned rather than assumed: a router served behind
another router, chunked bodies and finite ones alike, each hop declaring its own
framing. The console reads `front_door` too, so a preview's page says when the
change set's own router is serving it.
docs: state the user stories, and make them binding
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
a38cd51764
What the preview front door has to do was spread across a kustomization comment,
PLAN.md Phase 7, OPERATIONS.md §11h and the docstrings of the code that
implements it — and the rules settled while the router work was designed were
written down nowhere at all: which app a change set opens, the refusal instead of
a guess, the console as the human's answer, one address, merged means running,
and no step anyone has to remember. So they were policy by accident, and a change
could reach its own goal while quietly breaking one.

`USER_STORIES.md` is now the specification, and holds stories only: sixteen of
them in one numbered sequence, grouped by who has the need, each a statement a
person can check, with the rule that binds them at the top — every story holds
true when a change is finished, the story wins over any other document that
disagrees, and a story is changed in review rather than left stale.

The file carries no implementation, on purpose. The first draft did — which kind
of response framing crosses a hop, that the shared hostname's revision is a
literal in a committed manifest, that the router's source and its pinned revision
move together, the paths an app declares, image tags — and none of that is a
story: it is how the need is met today, and putting it here would make the
implementation the specification and the need a detail of it. What is left is
what a person wants. A change set's preview is that app, at one address, working;
the choice of app is mine to make, to change whenever more than one app could
serve it, and never to clean up; my change set runs my code, and a broken one
does not lock me out; a merge is enough, and what is running can be checked; a
preview holds no real credential and leaves no trace. The log-store story went
with the rest of it — "a change set about an app is served by that app" covers
the store — and so did the guard on the front door's revision, which is enforced
by `ci/router-tag-guard.sh` and the tests around it rather than promised here.

AGENTS.md carries the rule where an agent reads rules — Hard Rules — plus a line
in How Changes Ship saying that the suite, the preview and a green pipeline are
how the stories are checked and not a substitute for the check, the file in the
repo map, and the guard in the testing section. README.md says the same where a
human starts.

`tests/test_docs.py` keeps the file a spec: one sequence numbered from 1 (the
numbers are how a change or a test names a story, so a renumbering would repoint
every reference silently), a title and a body per story (a title alone is not a
check), no code fences, and none of them an essay — those thresholds are the
style the operator asked for, and they are what "concise" means here. The
assertion about AGENTS.md reads the Hard Rules section specifically: the first
version accepted the phrase anywhere in the file, and the repo map row says it
too, so deleting the actual rule still passed. Verified by removing the rule,
moving the pointer out of Hard Rules, skipping a number, leaving a story as its
title, and padding one into an essay: each fails, and none of them fails by
accident.
fix(preview): stop a preview's router waiting on a tag nobody built
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
9e77567e86
Pipeline #70 hung for twelve minutes and failed at `l3-preview`, while the
preview it was checking came up correctly a minute later.

Flux applies a preview's overlay the moment the branch moves — a minute after
the push, minutes before `build-router` has pushed anything — and the values
ConfigMap it substitutes from is still the previous run's. The router workload
was the one placeholder with a default, and that default named the revision the
front door pins: a tag `main` has still to build. So the first apply started a
pod that could never pull its image, Flux spent its entire ten-minute health
check on it (`health check failed after 10m0.015s: timeout waiting for:
[Deployment/preview-62/preview-router status: 'InProgress']`), and L3's own
wait on `Ready` failed with it. The values L3 had just written were fine;
writing a ConfigMap is not a trigger, so nothing ever read them.

Three changes, one race:

* `apps/preview/router.yaml` carries no default. Every tag a default could name
  is one a merge has still to build, which is the one tag a preview must not
  pull. Without it, a value that has not arrived is an empty image, which the
  API server rejects in a second — loud, quick, retried — instead of an
  ImagePullBackOff the gate waits ten minutes on.
* L3's fallback is the image the front door is *running*, read off the
  Deployment rather than out of a manifest. That revision exists by
  construction; the pinned one may not, for the reason above.
* L3 asks Flux to reconcile the Kustomization after writing the values
  (`reconcile.fluxcd.io/requestedAt`) and waits until
  `status.lastHandledReconcileAt` echoes that stamp before waiting on `Ready`.
  On a re-push the Kustomization exists and Flux already applied last run's
  values, so without this a preview keeps the previous revision until the next
  interval while the gate reports green. With it, green means the reconcile
  that carried this change set's values is the one that succeeded.

Verified against the live cluster as `preview-deployer`: the annotate is
permitted and the controller echoed the stamp within five seconds. Negative
controls: moving the request before the values write, or putting a default back
on the router image, fails `tests/test_static.py` ("the order is the argument",
and the no-default assertion). 181 passed, ruff and shellcheck clean, all nine
steps `sh -n` clean.
chore(preview): build and pin the console from this tree
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
59e158ba41
The deployed console is a literal on `main` — nothing in CI builds
`milner/oz:<pin>`, unlike the router — so a change under `oz/**` or
`previewlib/**` reaches the console only when someone bumps that line *and*
pushes the image by hand. This is that bump, for the front-door rule.

0.1.2 was built before `previewlib/selection.py` learned about a change set's
own router, so a console at 0.1.2 would decide what a preview shows by a rule
the front door no longer runs: one rule with two definitions, which is exactly
what the router's own pin exists to prevent. 0.1.3 is this tree — `/app/oz` and
`/app/previewlib` hash-match it, `front_door`/`router_for` are present, and it
is the *real* console rather than the preview variant (`/app/PREVIEW-BUILD`
absent).

Pushed as `milner/oz:0.1.3` (manifest digest sha256:b4c013a0…), so the merge
that moves the pin pulls an image that already exists; L2's no-PR fallback moves
with it. The Deployment also states `maxUnavailable: 0` / `maxSurge: 1` now: at
one replica the default computes the same rounding, but "the console does not
blink while it is upgraded" is worth a line a reader can see.
feat(preview): oz is the answer, and the console never drifts behind the tree
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
ci/woodpecker/pull_request_closed/cleanup Pipeline was successful
f66c053a49
Issue: oz.faun-snapper.ts.net doesn't show "select between oz and victoria-logs"
for preview-62, because:

  * the deployed console (milner/oz:0.1.1, three revisions behind the tree) has
    no Front door buttons, so the rule a person can press to disambiguate
    doesn't exist in the running image;
  * the rule itself refuses, but the candidate that the change set is about
    declares no paths, so it never reaches the refusal the rule writes — a
    change set two apps claim and one that nothing claims look the same;
  * the CI builds the router's main-push image but nothing builds the
    console's, so the console drifts behind the tree until an operator notices
    and pushes by hand.

This change closes all three.

Story 7 is the rule: oz is the only thing that picks when more than one app
qualifies. The router never picks programmatically, and a change set two apps
claim is refused by name, with the page pointing at the console that holds the
answer.

Story 17 is the infrastructure: every push to main rebuilds the console's image
from the source the merge moved, and a tag bump without a source change fails
the pull request before it merges. Symmetric with the router's guard and build,
with the per-PR half kept as the preview variant so a preview namespace runs
the simulation and not the console.

Files:

  * USER_STORIES.md                                — story 7 stronger, story 17 new
  * apps/preview/oz.yaml                           — oz declares its own paths
  * router/app.py                                  — refusal page names oz.<domain>
  * ci/oz-image.sh                                 — symmetric with ci/router-image.sh
  * ci/oz-tag-guard.sh                             — symmetric with ci/router-tag-guard.sh
  * .woodpecker/gates.yaml                         — build-oz main-push half, oz-tag-guard step
  * tests/test_ci_guards.py                        — 8 tests for the oz tag guard
  * tests/test_preview_router.py                   — rule tests for the new candidate
  * tests/test_static.py                           — pipeline and overlay assertions

197 passed; ruff clean; shellcheck clean; per-step sh -n clean.
tepichord deleted branch feat/60-integrate-victorialogs 2026-09-16 13:33:40 -07:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tepichord/milner!62
No description provided.