Create oz and preview service #56
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#56
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
preview-system: a stable preview URL with an admin console (
oz)Why
L3 already deploys every PR into its own
preview-<n>namespace and reconciles itwith Flux. What it does not give anyone is a place to look. The preview overlay
deletes the Tailscale Ingress, and the workload runs
registry.k8s.io/pause:3.10,which serves nothing — so the gate can go green while no human can see anything.
Goal: one stable, bookmarkable URL —
preview.faun-snapper.ts.net— that alwaysserves the preview a cluster_admin has selected, plus a small OIDC-gated console
at
oz.faun-snapper.ts.netto select, rebuild, and delete previews.Acceptance criteria
https://preview.faun-snapper.ts.net/serves the selected preview at the rootpath (no path prefix), so absolute asset paths, cookies, and client-side routers
behave as they would in prod.
the Tailscale side, and re-approves nothing.
ozrefuses anyone not in Pocket ID'scluster_adminsgroup.rebuild, or tear down anything.
oz(collected on the next reaper pass, shown aspending until then), and is deleted automatically when its PR is closed — merged or
abandoned — without an admin-console action.
button, or a manual pipeline run on the PR's branch — without pushing a commit.
itself, and
preview.*serves a "no preview selected" page rather than pointing ata namespace that no longer exists.
python -m pytest tests/ -vpasses offline, including new guards that the previewnamespace's render contains no Ingress and that the console/router RBAC is
bounded (no secrets, no wildcards, no Ingress create).
Design
Two hostnames, both permanent
oz.faun-snapper.ts.netpreview.faun-snapper.ts.netBoth Ingresses use the shared
ingress-proxiesProxyGroup, so no new tailnet deviceis created. Each is a Tailscale Service, so each needs one approval at setup (or
one
autoApprovers.servicesentry each, then zero). After that neither is evertouched again — which is the entire point of this design.
Data path
The router resolves its target from the API, not from a mounted file: a projected
ConfigMap can lag by the kubelet sync period plus cache TTL (a minute or more), which
would make switching slower than doing nothing. The router watches namespaces and
Services and swaps the upstream in memory. In-flight requests finish against the old
target; there is no dark window.
State: a label on the selected preview, not a ConfigMap
milner.dev/preview-active: "true"as a label on the selectedpreview-<n>namespace.
Rejected alternative: a
preview-activeConfigMap inpreview-system. It survivesits target's teardown, so a reaped preview leaves the router pointing at nothing until
someone re-selects. A label on the namespace cannot outlive the namespace, which is
why criterion 7 falls out for free.
Preview namespaces keep having no Ingress
apps/preview/kustomization.yamldeletes the Ingress and must keep doing so. Thereason currently written in that file — "a preview must not mint a tailnet node" —
is wrong: with a shared ProxyGroup an Ingress creates a Tailscale Service, not a
device. The real reasons are:
ingress-for-pg.go:"multiple Ingresses for the same hostname in the same cluster are not allowed"), so
preview.*can only ever point at one namespace at a time; andside effect of opening a PR.
Fix that comment and the docstring of
tests/test_static.py::test_preview_overlay_strips_the_tailscale_ingressto say so.Prerequisites (critical path first)
P0.1 — publish the images to zot.
ozis hand-written, so it needs an image — and this repo has never built one (noDockerfile, no build step anywhere). That is the critical path for this issue and isprobably worth its own ticket. Blocked by the zot work landing on
main(
feat/48-zot-oci-service, unmerged at the time of writing).How the kubelet pulls it — verify before building, because it decides whether a
Terraform change is needed. The repo already proves a node can reach a
*.ts.netname:
control-plane/main.tfsetscluster.apiServer.extraArgs."oidc-issuer-url" = "https://id.faun-snapper.ts.net", anddashboard login works — so the kube-apiserver fetches discovery and JWKS over the
tailnet from the control plane. If the same holds for the worker, the kubelet can pull
zot.faun-snapper.ts.net/<repo>over HTTPS with a real cert and no Talos change atall. Test it (debug pod on the worker:
curl -sv https://zot.faun-snapper.ts.net/v2/).If it does not hold, the fallback is
zot.zot.svc:5000over plain HTTP, which needsmachine.registriesin the worker's Talos config — a change inworker/plus aterraform applyyou run — and it makes the worker trust an unauthenticated registry.Where it is built and how it is pinned. Build in Woodpecker (
woodpecker-runtimeisalready
privileged, sobuildah/kanikois viable) and push to zot. Tag with thecommit SHA (
sha-<sha>) and reference that tag in the manifest: no digest write-backloop, so CI still never commits to the repo, and the tag names its own commit. Watch
the interaction with zot's catch-all retention tier (20 most recent, or pushed within
90 days) — an old
sha-*tag ages out, so bump the manifest rather than letting a stalepin turn into
ImagePullBackOff.The alternative is
:main+imagePullPolicy: Always+ a pod annotation carrying theSHA to force a rollout. That is mutable by design, which is exactly what
policy/k8s.regorejects:latestfor — so it is a deliberate exception, not a freewin. Either way the first push has to precede the manifest that references it, or Flux
reports not-ready against an image that does not exist yet.
P0.2 — verify pod-reachable OIDC.
curl https://id.faun-snapper.ts.net/.well-known/openid-configurationfrom a pod. Thenode-side path is proven (P0.1), but pods resolve through CoreDNS, not necessarily the
node's resolver, and the same class of problem already cost time with Pocket ID's
internalAppUrl. If it fails,ozneeds an internal issuer URL while its redirect URIstays external.
P0.3 — Pocket ID client. Create an OIDC client for
ozwith redirect URIhttps://oz.faun-snapper.ts.net/callback, and confirm the ID token carries agroupsclaim containing
cluster_admins.P0.4 — teardown: three triggers, one recipe.
oz(console)pull_request_closedpreview-reaperCronJobThe expiry row is not redundant with the other two: the close-triggered pipeline is
driven by the same Forgejo delivery as everything else, so both automatic paths fail
together — and neither covers a PR that is simply left open. Expiry is the only path
that works with the forge unreachable, Woodpecker down, and no event ever arriving.
The recipe, in this order (the Kustomization first, so its pruner collects the
workloads):
milner-pr-<n>inpreview-systempreview-<n>-valuesinpreview-systempreview-<n>milner-pr-<n>inpreview-systemSo that one recipe is addressable from three callers, label all four objects with
milner.dev/preview-pr: "<n>". Every teardown then reduces tokubectl delete <kind> -l milner.dev/preview-pr=<n>, and the namespace label is alsowhat
ozlists previews from.That lets the reaper stop judging four objects independently: it should find expired
previews from the namespace annotation alone (
milner.dev/preview-expires), thendelete all four by label. Today each object is judged on its own annotation, so
extending or deleting one of them leaves a window where the Kustomization is reaped and
the namespace is not — which prunes the workloads out from under whoever is looking at
the preview. This changes shipped behaviour, so
tests/test_static.py::test_preview_reaper_deletes_expired_resources_by_annotation(and its ≥4-occurrence assertion, which now reads
.woodpecker/gates.yaml) must changewith it.
TTL: 30 days, up from 4 hours. A preview is an artifact you come back to, not a
four-hour window: at 4h a preview activated today for a demo tomorrow is already gone,
and rebuildable only by spending a pipeline run (P0.5). A month is long enough that
expiry is pure hygiene and never interrupts a review — which is why the issue does
not need an "exempt the active preview" rule. Rejected for the record: exempting
the selected namespace from expiry; unnecessary at a 30-day TTL, and it would let one
namespace hold a PVC indefinitely.
Budget note, since this is the change with a resource consequence: the preview
overlay's 1 Gi PVC uses the
ephemeralclass, which lives at/var/mnt/data/ephemeralon the same 100 GiB shared data disk aspersistent/.The per-namespace ResourceQuota does not bound the total, so the live-preview count is
bounded only by "PRs opened in the last 30 days" — roughly 10 GiB if you open ten a
month. Acceptable, but it is the number to watch, and it is an argument for using the
delete button rather than waiting for expiry.
Keep the TTL in one place — a Flux-owned
preview-configConfigMap inpreview-systemwith attl_secondskey, read by L3 (kubectl get -o jsonpath) andby
oz. Two literals in two languages is the same drift bug as the stand-in imageduplicated across
l2-dry-runandl3-preview.Identity for each path — decided.
CI already has it.
preview-deployerholds namespace create and delete, plusCRUD on kustomizations/gitrepositories/configmaps in
preview-system(
preview-system/rbac.yaml). No new grant.ozgets patch, not delete. Its Delete button patches the namespace'smilner.dev/preview-expiresto now — a verb it already needs for activation — and thereaper collects the preview on its next pass. Three things to build around:
of removing the row, or the button looks broken and gets clicked twice;
*/15was chosen when the onlyconsumer was a 4-hour expiry; with a user-facing delete in front of it,
*/5is theright cadence, and it costs a 10m-CPU/32Mi pod five times an hour instead of three;
a new commit legitimately rebuilds it — but a run overlapping the reaper's delete
pass can leave the Kustomization not-ready until the next L3 run re-applies it. The
race exists today, just narrower.
Why this over the alternatives: the console can then expire but never destroy,
which is a stronger statement than any grant list.
rbac.yamlgives the reaper its ownidentity precisely because "deleting namespaces cluster-wide is strictly more
dangerous than creating them, and the CronJob runs unattended" — (b) leaves that
boundary intact and gives teardown one implementation, shared with expiry. Rejected:
(a)
namespaces deleteonoz— a compromised console could deletevaultorflux-system; (c) a one-shot Job under the reaper's SA — immediate andidentity-preserving, but it adds a Job-spawning path and a second way to say "delete
this" for a latency nobody is waiting on.
Woodpecker mechanics — decided: split into a directory.
pull_request_closediscurrently filtered out by the root
whenin.woodpecker.yaml, and Woodpecker loadseither
.woodpecker.yamlor the.woodpecker/directory, not both. So:.woodpecker.yaml→.woodpecker/gates.yaml: the four validation gates, unchangedapart from the items below;
.woodpecker/cleanup.yaml: the close-triggered teardown, withwhen: [{event: pull_request_closed}]on the step.The required status check therefore changes from
ci/woodpecker/pr/woodpeckertoci/woodpecker/pr/gates. Confirm the branch-protection setting before merging this.It is documented as requiring the pattern
ci/woodpecker/pr/*, which still matches — butif Forgejo is holding a literal check name rather than the pattern, the rename blocks
every merge until it is updated, and that is the one step here whose mistake is not
self-announcing. The cleanup pipeline reports under the
pull_request_closedevent, soit never contributes a check to an open PR.
Rejected alternative:
- event: pull_request_closedon the rootwhenplus per-stepwhenblocks on all four gate steps. It keeps one file and the check name, at the costof every gate step carrying an event list for a concern that is not theirs.
P0.5 — rebuild a deleted preview in one action.
With a 30-day TTL, expiry is rare; but a preview deleted from
oz(or expired, orcollected after a merge) has to come back cheaply. Today the only route is pushing a
commit to the PR, which re-runs L3 with
CI_COMMIT_PULL_REQUESTset. Requiring an emptycommit for a preview you deleted by mistake is the wrong ergonomics.
Three routes, cheapest first:
needs a dirty-tree dance. Keep as the fallback.
previous PR pipeline). Needs one change: L3's
whencurrently allows onlyevent: pull_request, so a manual or branch run never reaches it.oz—POST /api/repos/{repo_id}/pipelineswith a Bearer PAT andbody
{branch, variables}, which Woodpecker documents as "Trigger a manualpipeline" and injects into the step environment. Needs the same
whenchange plus atoken.
The change that makes 2 and 3 both work is PR-number resolution in L3, since a manual
run has no
CI_COMMIT_PULL_REQUEST:The
exit 0guard matters: a manual run of the gates onmainmust not try to create apreview. The forge lookup is what makes route 2 work with no variables at all, and it is
the only tier that needs the network — the repo is public, so it can be anonymous.
For route 3,
ozneeds:http://woodpecker-server.woodpecker.svc.cluster.localrather than the tailnethostname, which sidesteps P0.2's resolution question for this call;
but for a deleted one it is gone — so
ozshould list open PRs from the forge(
GET /api/v1/repos/tepichord/milner/pulls?state=open) and correlate them with livepreview namespaces. That also fixes the console's discoverability problem: without it,
a deleted preview vanishes from a namespace-derived list and the Build button has
nothing to attach to.
Rejected:
ozrecreating the preview objects itself. That duplicates L3's recipe(constructing a GitRepository pinned to the branch, a Kustomization with the right
target namespace and substitutions) inside the console, and widens its grants to
kustomizations, gitrepositories, and configmaps. Triggering the pipeline that already
knows how keeps one implementation.
Platform work
preview-system/router.yaml— router Deployment + Service + Ingress(
tls.hosts: [preview],tailscale.com/proxy-group: ingress-proxies), listening on:8080, image pinned by tag, resources + limits,runAsNonRoot,readOnlyRootFilesystem, all capabilities dropped. Target resolution: the namespacelabelled
milner.dev/preview-active, then the Service in it labelledmilner.dev/preview-service: "true"(label-selected rather than hard-codingorchestrator, so a second app works later). Refuse any target that is not*.preview-<n>.svc.cluster.localfor a namespace that exists and carries the previewlabels. Pass
Upgrade/Connectionthrough for WebSockets and setX-Forwarded-*.Serve a "no preview selected" page when the label is absent, and a distinct page when
the namespace exists but has no ready endpoints.
preview-system/oz.yaml— console Deployment + Service + Ingress(
tls.hosts: [oz]), plus its SA and RBAC in this layer, so the app, its SA, andits grants land atomically under
wait: trueand the preview identities stay in oneplace.
preview-system/config.yaml— thepreview-configConfigMap holdingttl_seconds.preview-system/rbac.yaml— add apreview-consoleSA withnamespacesget/list/watch,
namespacespatch, andservicesget/list/watch. No delete verbof any kind, and no Ingress create/delete: the console asks the reaper to collect, it
never destroys anything itself (P0.4). Extend
tests/test_static.pyso the existing"no secrets, no wildcard" ClusterRole check covers this identity too, and assert it
holds no
deleteverb.preview-system/reaper.yaml— expiry from the namespace, deletion by label (P0.4),and the schedule shortened to
*/5 * * * *.preview-system/kustomization.yaml.Where it lives: recommended
preview-system/, keeping all preview machinery in onelayer.
apps/oz/also works but splits the RBAC from the other preview identities andapplies in an earlier layer.
Console (
oz) behaviourcluster_adminsin thegroupsclaim; signed,httpOnly,Secure,SameSitesession cookie; session keyfrom Vault via VSO; CSRF token on every state-changing POST.
live (activate / delete), expired or never built (build), plus the active
marker, the source branch, and the expiry countdown.
milner.dev/preview-activefrom any other preview, sets it on thechosen one, and extends that namespace's expiry to the full TTL.
pipeline URL so the operator can watch it.
moves the row to a pending state naming when collection will happen — the console never
deletes anything itself.
values are a validated PR number and a validated preview identifier. Renders no
user-controlled HTML.
CI work (
.woodpecker/)l3-preview: replace theregistry.k8s.io/pause:3.10stand-in with an HTTP server,so an activated preview shows something. Constraints from
apps/base/deployment.yaml:non-root (uid 65535),
readOnlyRootFilesystem, and the Service'stargetPort: httpis 8080.
hashicorp/http-echofits (static binary,-listen=:8080); nginx-familyimages do not run under that context. Add matching
argsin the preview overlay sothe page names the PR.
l3-preview: addmilner.dev/preview-pr: "<n>"(and the source branch) as alabel at namespace creation, and put the same PR label on the values ConfigMap,
the GitRepository, and the Kustomization (P0.4's recipe depends on all four).
l3-preview: read the TTL frompreview-configinstead of the literal4 * 3600, and rewrite the comment above it — the current one ("short enough that aforgotten PR does not hold a namespace overnight") argues for the behaviour that is
being replaced.
l3-preview: PR-number resolution per P0.5 (CI_COMMIT_PULL_REQUEST→PREVIEW_PR→ forge lookup by branch), with the non-numeric/absent guard, and add
{event: manual}to L3's ownwhen.l2-dry-run: keep the stand-in literal in sync with L3 and add a test asserting thetwo match — they are two copies of one fact today.
l3-preview: drop the hard-codedhealthChecks: Deployment/orchestratorand rely onwait: true. The current check names a Deployment regardless of what was applied, sorenaming or adding a workload is never health-checked.
.woodpecker/cleanup.yaml(P0.4): apull_request_closedstep implementing the fourdeletes by label. It must be idempotent — the preview may already be gone (expired, or
deleted from
oz) — and must not fail the run when there is nothing to delete. Movethe existing file to
.woodpecker/gates.yamlin the same commit, and update its headercomment: it currently spells out the context derivation as "
.woodpecker.yamlgiveswoodpecker", which is now wrong — and that paragraph is the documentation of theexact string branch protection depends on.
path: ./apps/previewalone. Previewing "what the PR changed" is a separateissue: with one app in the repo the mapping is near-degenerate, and the convention
that scales (
apps/<name>/{base,prod,preview}, changed paths → that overlay) is arestructure, not a CI tweak.
Note the preview overlay already needs no
${domain}: it deletes the Ingress andpatches
storage_classtoephemeral, so${image}is the only placeholder left andL2's substitution needs no change. Add a guard test anyway — every
${...}inrendered-apps-preview.yamlmust have a value in the L3 values ConfigMap, so the nextplaceholder added to the base cannot silently break the dry run.
Docs
OPERATIONS.md§2: the two Ingresses and their one-time approval, plus optionalautoApprovers.servicesentries forsvc:ozandsvc:preview; §11d: a preview drill(activate → verify → delete → rebuild → verify the same namespace returned →
expire → verify the selection cleared); the teardown paths and which identity
performs each; the TTL and where it is configured.
AGENTS.mdrepo map: thepreview-system/row, and theapps/preview/row (whichcurrently repeats the "must not mint a tailnet node" claim).
ARCHITECTURE.mdandPLAN.md: the console, the router, and the image build.README.mdif it describes the preview pipeline.Verification
Offline:
python -m pytest tests/ -v;kustomize build apps/preview(must contain noIngress);
flux build kustomization preview-system --path=... --kustomization-file=...;kubeconform -strict -ignore-missing-schemas+conftest test --policy policy/over therender.
Live, in order:
preview.*serves A at/, and the page identifies A.change to the ProxyGroup's serve config or the tailnet's service state (capture
before/after).
oz→ the row moves to pending, then within one reaper tick all fourobjects are gone, the console no longer lists it as live, and if it was active
preview.*serves the "no preview selected" page.oz→ the samepreview-<n>namespace returns and reconciles toReady, with the expiry reset to the full TTL. Repeat via a manual pipeline run on the
branch, then once on
mainto confirm the run logs "nothing to preview" and exits 0.already-deleted preview succeeds without error.
oz; an unauthenticated tailnet member canview
preview.*but can neither select, rebuild, nor delete.Non-goals
Per-PR hostnames or slot pools; path-prefixed previews (
preview.*/45); previewing whata PR changed rather than a fixed overlay; building the previewed app's own image (only
oz/router images are in scope); Postgres — the selection lives in the clusterdeliberately, so there is one source of truth.
Evidence for the claims above
cmd/k8s-operator/ingress-for-pg.go— one Ingress per hostname per cluster,cluster-wide and path-blind:
// It is invalid to have multiple Ingress resources for the same Tailscale Service in one cluster.→found duplicate Ingress %q for hostname %q - multiple Ingresses for the same hostname in the same cluster are not allowed.cmd/k8s-operator/ingress.go::handlersForIngress— the proxy backend is the ServiceClusterIP, and the Ingress path is appended:
Proxy: proto + net.JoinHostPort(svc.Spec.ClusterIP, fmt.Sprint(port)) + path.POST /repos/{repo_id}/pipelines, "Trigger a manual pipeline",Bearer PAT,
PipelineOptions { branch: string; variables: Record<string, string> };the CLI documents the same custom parameters as "custom parameters to inject into the
step environment". This is what makes P0.5's routes 2 and 3 possible.
control-plane/main.tf—cluster.apiServer.extraArgs."oidc-issuer-url" = "https://id.faun-snapper.ts.net"with working OIDC login: a node reaching a*.ts.netname is already load-bearing in this cluster, which is why P0.1 tests the tailnet path
before falling back to
machine.registries.data/main.tf— the shared data disk is 100 GiB;infrastructure/local-path/values.yamlputs the
ephemeralclass (Delete) at/var/mnt/data/ephemeralon that same disk,which is the budget the 30-day TTL spends.
preview-system/rbac.yaml—preview-deployeralready holds namespace delete and thethree preview-system kinds, so the cleanup pipeline needs no new grant; and the reaper
is a separate identity because cluster-wide namespace deletion is the dangerous verb,
which is what makes P0.4(a) a real decision.
ProxyGroup", and each unique hostname costs a Let's Encrypt certificate (50 unique
hostnames per week) — why per-PR hostnames were rejected.
(
operatorConfig.defaultTags,proxyConfig.defaultTags); there is no supported way totag an operator-created Service, which is why per-PR auto-approval is unavailable and
a constant hostname is the only zero-touch option.