Create Annia pod #25
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#25
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?
apps/kustomization.yaml.
Naina:
Updated on 2026-09-30 based on maintainer feedback.
Overview
Run Annia on milner: the persistent Deployment, the serialized KEDA ScaledJob watching
annia.dispatch, the three Vault reads it needs (clone key, repo env, Forgejo token), the shared/nixand/root/.pimounts, Flux image automation, and the tripwires and docs that move with them. Body items 1–4, 6, 7 and 8 have landed (RabbitMQ, KEDA 2.20.2, theannia.dispatchtopology, the enqueuer, its Cloudflare route, and the LLM egress Service), so this plan is items 5, 9 and 10 only. Every live criterion is blocked byannia-infra#310(nomilner/anniaimage is published today);/root/.piadditionally depends onmilner#21, and theannia-nixclaim's shape on the layered-store issue.Technical Approach
ci/milner, plus the named policy path. Note the nesting:transformationgoes underspec.destination, andexcludeRaw: trueis required so VSO's synthetic_rawdoes not land in the Secret1.annia/env(one field perenvironments/*.envfile) andci/forgejo; samekv-v2/vaultAuthRef: defaultshape as above, no transformation.annia-readalready coversannia/env; the twoci/paths are the new grants./nixis the shared store (story 19),/root/.piis the Syncthing-synced folder (milner#21). The/workspaceclaim is deleted: the container layer is the workspace.RWO means the annia pod and the Syncthing pod must be in one namespace and on one node;
nodeSelector: node-role: workerplus the single worker makes that free today.deployment.yamlis the enqueuer's andci/enqueuer-tag-guard.shpins its image line2.args:only:command:would replace the image's ENTRYPOINT3, so the clone, key wiring and git config never run4.securityContextstays unset:anniaisbaselineand the image is root. There is no/workspacevolume;/workspace/environmentsis the only thing mounted under it.hostFromEnvresolves from the Job container's env, so there is no TriggerAuthentication and no Secret inkeda5. The URI's/anniasupplies the vhost, sovhostNameis omitted.keda.sh/scaledjobsfor thedefaultServiceAccount, which is what the landed VaultAuth binds6.The
flux-write-deploy-keymust be write-enabled; Forgejo reserves force-push/rewrite history for Write and above11 12, and the "second bump updates the same PR" criterion depends on it.docs/nix.md§14b/§14c/§14d (add a builder-pod row; reframe "one writer, ever" as the GC rule and record the append-only interim);docs/vault.md§4c-ter (the twoci/policy paths);OPERATIONS.md(clone-key/annia/envrunbook,flux-write-deploy-key, image automation, pause runbook);AGENTS.md(repo map row, and the/root/.piVault-first standing exception);USER_STORIES.mdstory 20 scoped to apps whose source is fixed at build time.Implementation Details
gitops/clusters/milner/apps/annia/annia-deployment.yamldeployment.yamlis untouchedgitops/clusters/milner/apps/annia/annia-nix-pvc.yamlnix-class claim, provisionalgitops/clusters/milner/apps/annia/annia-pi-pvc.yaml/root/.pi, whole volume, shared with Syncthinggitops/clusters/milner/apps/annia/scaledjob.yamlssa: Merge13,queueName: annia.dispatchgitops/clusters/milner/apps/annia/rbac.yamlkeda.sh/scaledjobsfor SAdefaultgitops/clusters/milner/apps/annia/{image-automation,gitrepository-write}.yamlgitops/clusters/milner/apps/annia/kustomization.yamlgitops/clusters/milner/secrets/annia-secrets.yamlgitops/clusters/milner/apps/annia/set-credential.shPOLICY_HCL; add the three<NAME>_PATH=constantsgitops/clusters/milner/flux-system/gotk-components.yamltests/test_gitops.py,policy/k8s.rego_containersarmConstraints: the ScaledJob is a
keda.shCR, so it stays inapps(a layer later than theinfrastructureHelmRelease); the KEDA row lands now ortest_crd_providers_are_not_in_the_layer_that_uses_their_crdsfails with "nothing applies keda.sh resources"1415;RABBITMQ_HOSTmust carry anamqp://scheme and the/anniavhost;transformationnests underspec.destination; the SSH key mount needsdefaultMode: 0400withreadOnly: true; PVC sizes are nominal under local-path.Dependencies: landed KEDA 2.20.2, the
annia.dispatchtopology, and Flux v2.9.4;annia-infra#310for the image;milner#21for the/root/.pifolder; the layered-store issue for the/nixclaim's final shape.Out-of-scope notes
annia-infra#310owns: publishingmilner/annia:<semver>, making the image runnable non-interactively (argspassthrough inentrypoint.sh), staging pi in the image, and failing on a missingenvironments/*.envinstead of skipping it.reject-not-nackclient change makesx-delivery-limit: 2actually fire; it is annia-infra's.lain-milner-externalprovider in.annia.tomlis a later test, not this change; the LLM egress Service already landed.apps/evolved-npcs/, the enqueuer manifest and its tag guard, and the six-layer list are untouched.Acceptance Criteria
python -m pytest tests/ -vis green with the VSO tripwires matching the three new reads (ci/milner,annia/env,ci/forgejo):len(paths) == 17and(9, 17), thekeda.shrow, and the extended_pod_specs().conftest teston the renderedappslayer reachesspec.jobTargetRef.template; reverting the Rego_containersarm makes the case fail.queueName: annia.dispatch,hostFromEnv: RABBITMQ_HOST,maxReplicaCount: 1,backoffLimit: 0, the sharednixclaim, andssa: Merge.args: ["start"](nocommand:), has no/workspacevolume, and mounts/nix, the whole-volume/root/.pi, the0400key, and/workspace/environments.annia-infra#310/milner#21: the pod isRunningwith the mounts andenv | grep -E 'FORGEJO_TOKEN|RABBITMQ_'populated; one message onannia.dispatchmints exactly one Job.autoscaling.keda.sh/paused: "true"set in Headlamp stops job creation and survives a Flux reconcile; an image bump opens one PR viarefs/for/main, and a second bump updates it.Testing Plan
python -m pytest tests/ -v— render, kubeconform, conftest and the tripwires above.conftest test --policy policy/on a rendered ScaledJob; revert the_containersarm and observe the failure.flux build kustomizationforsecrets,appsand the regeneratedflux-system.#310):kubectl -n annia exec deploy/annia -- env | grep RABBITMQ_; publish one message and watchkubectl -n annia get jobs -w;flux get images all -A.kubectl -n annia get jobsanswer each way.Related Issues/PRs
tepichord/annia-infra#310(publishmilner/annia);/root/.piblocked bymilner#21;/nixcoupled to the layered-store issue.infrastructure/nix-store/persistentvolumeclaim.yaml(thenixclaim),apps/pocket-id/persistentvolumeclaim.yaml(Flux-ownedpersistentclaim),apps/annia/deployment.yaml(the enqueuer's env mapping),secrets/pocket-id-database.yaml(destination.transformation+excludeRaw).Research Findings
mode: QueueLength+value(queueLengthis deprecated);vhostNameis optional and the AMQP URI's vhost wins when omitted.hostFromEnv/usernameFromEnv/passwordFromEnvresolve from the Job container's env, so no TriggerAuthentication is needed;maxReplicaCount: 1serialises Jobs, but aFailedJob stops counting — the queue'sx-delivery-limit: 2is the real crash-loop bound.transformationbelongs underspec.destination.transformation, andexcludeRaw: trueis needed alongsideexcludes: [".*"]to drop the synthetic_rawfield.OverrideSSA policy strips a hand-added annotation on reconcile;kustomize.toolkit.fluxcd.io/ssa: Mergepreserves it for other field managers while Git-declared fields stay enforced.refs/for/mainand needstopic+force-push: "true"to update the same PR on a second bump;push.refspec's local half must equalpush.branch.flux install --exportshould pass--version=v2.9.4; the extra components add their Deployments, RBAC and the image-toolkit CRDs, and the change is not byte-stable if the CLI version drifts.ReadWriteOncepermits multiple pods on one node, so the Syncthing/annia shared/root/.piclaim is sound while both are pinned to the worker.nix-collect-garbageagainst a live store, not concurrent population, so "one writer, ever" should be reframed as the GC rule until the layered store lands.Pre-Mortem
milner/anniaimage never lands (annia-infra#310) — Every live acceptance criterion and theImagePolicysemver depend on a published image tag, so only manifest skeletons can merge while theappslayer'swait: truestays red. Because placeholders are banned, the work cannot satisfy its own live criteria and stalls at the offline gate.gotk-components.yamlwithflux install --export --version=v2.9.4can drift with the CLI's embedded version and mismatch the image-toolkit CRDs with its controllers. Even when installed correctly, "a second bump updates the same PR" needs a write-enabled deploy key plustopicandforce-push, and that registration is an unconfirmed external step the plan itself flags.17and(9, 17)), add the("keda.sh", "keda", "KEDA")row, and extend both_pod_specs()andpolicy/k8s.rego's_containersarm. Missing or mis-pathing any one of these leavespytest/conftestred or silently lets the Job container escape the CPU/memory limits rule.tepichord referenced this issue2026-09-22 14:12:03 -07:00
Decisions from review, and the plan against what has landed
The plan above still holds in outline. This records what has merged since it was written, one decision that changes the store design, and the
corrections that follow from both.
1. Landed since the plan was written
anniavhost topologydata/annia/rabbitmq.yamlinfrastructure/keda.sh×4,eventing.keda.sh×2), namespacerestricted, HelmRelease Ready, 3 podsv1beta1.external.metrics.k8s.ioAvailableinfrastructure/namespaces/annia.yaml, theanniaVaultAuthsecrets/vaultauth-annia.yaml(roleannia, serviceAccountdefault)So the remaining work is the body's items 5, 9 and 10 — the pod, its secrets, and the ScaledJob — not items 1–4.
2. Decision: annia is a builder, so it mounts the shared store
Annia clones its own sources and runs them; its environment is the flake of the repo and branch under work (today
annia-infraandmilner, infuture dozens). It builds. So story 19 governs it — "I mount a
nixvolume at/nixin whatever builds in this cluster — a pipeline step, anynamespace" — and story 20 governs apps whose source is fixed at build time, not this.
That makes
apps/annia/nix-pvc.yamlwrong as written. It is theephemeralclass: a private, empty volume, sized for one environment. It must bethe
nixclass, whose constantpathPattern: sharedresolves every claim of that class, in any namespace, to the one directory/var/mnt/data/nix/shared(docs/nix.md §14b). A private store cannot work here: it starts empty, and a mount over/nixhides the image's own store,so the pod cannot exec its entrypoint (§14c, §14d) — and it would share nothing with the builds that already realised both shells.
Two consequences:
20Ginominal is moot.shared warm store, a Job that mounts nothing re-realises every environment it touches on every wake-up.
/workspaceand/root/.pistay as they are — thepersistentclass, opaque state the pod writes and reads back. The/nixmount is the only onethat changes.
3. Corrections to the plan as written
queueName: annia-wakeups; the landed topology declaresannia.dispatch. Theenqueuer publishes to
annia.eventswith routing keymilner(enqueuer/app.py:61–62). The crash-loop bound the maintainer asked for has landed:annia.dispatchis a quorum queue withx-delivery-limit: 2andx-dead-letter-exchange: annia.dlx, asserted intests/test_gitops.py:1505–1556. Thereject-not-nackchange is the only remaining half of that defence.CRD_PROVIDERSgaining("keda.sh", "keda", "KEDA"): the tripwireasserts nothing consumes
keda.shuntil something does. The first ScaledJob is that something, so the row must be added here or the suite fails.expected 9 VaultAuths and 14 VaultStaticSecrets(
tests/test_gitops.py:783). TheanniaVaultAuth already exists, andannia-rabbitmq-credentials(pathannia/rabbitmq) already exists in thenamespace — so this change adds one VaultStaticSecret, the clone key:
(9, 15).secret/ci/milner → SSH_KEY— the existing key, no new keypair (tepichord/annia-infra#310settles where it lives, andthe container there uses the same one). Two consequences here: the
anniaVault role's read-only policy must include that path, and the Secret-mountedprivate key needs
defaultMode: 0400withreadOnly: true, becausesshrefuses a key that is group- or world-readable./workspaceclaim. The image stages.annia.toml,.agents/skillsanddiagnosticsat/workspace/…,which worked under Docker's
VOLUMEand does not under Kubernetes — the PVC hides it, exactly as a store mount hides/nix. The image must stage themelsewhere and
entrypoint.shmust seed an empty/workspacefrom there. That change belongs toannia-infra#310; this issue depends on it.policy/k8s.regostill needs the_containersarm forScaledJob, whose containers live atspec.jobTargetRef.template.autoscaling.keda.sh/pausedapplied by hand in Headlamp — as doesmaxReplicaCount: 1.4. Open before implementing
shared store the writers are CI's serial steps, the Deployment, and Jobs — and
maxReplicaCount: 1serialises Jobs against each other and nothing else.Decide between accepting that and serialising it.
milneras well asannia-infra, that is a second grant on the same key's account; if it opens pullrequests, it needs a token, and
entrypoint.shreads none today.annia-infra#310: the repository path and tag convention (milner/annia:<semver>would match the rest of theregistry), and whether the tag-tracking automation earns its place while a bootstrap image changes rarely.
pod that builds; its table wants a row for "a builder, any namespace". Story 20 arguably wants scoping to apps whose source is fixed at build time. Per
AGENTS.md the correction lands in the change that contradicts it — this one.
5. Sequencing
tepichord/annia-infra#310first. There is no annia image to run today — the registry holdsmilner/annia-enqueuerand nothing else in that family —so the pod's live criteria are unreachable until that pipeline publishes one, and #310 is not blocked by this issue.
Naina:
Plan revision — the 2026-09-28 decision session
Body items 5, 9 and 10 still stand, and the technical approach is otherwise unchanged. The pod's shape changes as follows; the parts not named here hold as written.
1. Drop
command:— the entrypoint must stayKubernetes
command:replaces the image's ENTRYPOINT. Withcommand: ["annia"](in both the Deployment and the ScaledJob)/entrypoint.shnever runs, so the clone, the SSH key wiring and the git config do not happen. Passargs: ["start"]andargs: ["run"]only and let the ENTRYPOINT do its job — the image'sentrypoint.shgainsif [ "$#" -gt 0 ]; then exec "$@"; fifor exactly this (annia-infra#310).2. Delete the
/workspaceclaim and its mountThere is no
/workspacevolume in-cluster: the container's own writable layer is the workspace, exactly as it already is locally. Delete theannia-workspacePVC, the/workspacemount, and thepisubPath with it. This is one path on both machines, the image's payload (.annia.toml,.agents/skills,diagnostics/http-tap.py) is simply visible, the clone is unconditional on every start, and nothing durable is lost — the durable parts are the nix store and/root/.pi, the backlog lives in Forgejo,.annia/is scratch, and the artefact of a run is a commit or a pull request.3.
/root/.piis the shared Syncthing PVC (milner#21)/root/.pibecomes a whole-volume mount (nosubPath) from apersistent-class PVC that the cluster's always-on Syncthing pod (milner#21) also mounts. That pod owns the foldernaccs-5znau("pi" = the operator's~/.pi) and keeps it send/receive in sync with the operator's machines, so the pod's/root/.piis the operator's folder — credentials, skills, extensions, session history, all of it — and what annia writes lands back on the operator's host.ReadWriteOnce, so the Syncthing pod and the annia pod must land on the same node. Both selectnode-role: workerand there is one worker today, so this is free; it is a real constraint the day a second worker exists..annia.toml's hardcoded-e /root/.pi/agent/npm/node_modules/pi-web-accessis correct in the pod with nothing baked into the image. Nothing installs extensions at pod start.npm install -ggoes away; only the state and extensions come from the folder./root/.pidepend on milner#21 shipping, so #21 moves onto this issue's critical path.4. No Vault path for pi's credentials — a documented standing exception
The folder carries pi's provider keys (
agent/auth.json,models.json'slain.apiKey), so there is nosecret/annia/pi*path, noVaultStaticSecretfor pi, and no template rendering. The~/.pimount is the source of truth.AGENTS.md's "Secrets are Vault-first" therefore gains a standing exception, written down where it is used (the way the Tailscale operator's OAuth client is) — the operator has confirmed this, and the exception sentence is part of this change.The in-cluster provider is a named provider in the operator's own
models.json—lain-milner-external→http://llm-gateway.annia.svc.cluster.local:4000, same shape as the existinglain, pointing at the already-landedllm-gatewayService.lain-llm.tailc23d6.ts.netstill never appears in-cluster. Selecting that provider in.annia.tomlis a later test, not part of this change (today's default is deepseek).5.
environments/*.envcome from Vaultsecret/annia/env(one field per file, the field name is the file name) synced byVaultStaticSecretannia-repo-envinto a Secret, mounted read-only at/workspace/environments— so the two files arrive at exactly the paths.annia.tomlnames, with no templates and no policy change (annia-readalready coverssecret/data/annia/*). A missing file now fails instead of being skipped (annia-infra#310 flips the crate's behaviour and its test).6.
FORGEJO_TOKENaccess is missing today and is owed heresecret/ci/forgejo→FORGEJO_TOKENexists and is byte-identical tosecret/forgejo/token, but the pod has no access: noVaultStaticSecret, no env entry, and theanniaVault role covers onlysecret/data/annia/*. Add the access —secretKeyReffrom a synced Secret (env is what the skills read) — with the same named-path policy argument the clone key already carries.7. The
annia-nixclaim is provisionalUnder the layered store (
/nix/store= image lower + shared volume upper), the consumer's claim and its/nixmount stay, but the backing directory becomes the merge point the node-side assembler owns rather than the whole store. The A-shaped claim (storageClassName: nixat/nix) must not be copied forward — it is re-derived by the new issue Convert the shared nix store to a layered store, which also deletes the hand-run seed Job and thedocs/storage.md§10g section that papered over the empty store.8. Unchanged
The Flux
ImageRepository/ImagePolicy/ImageUpdateAutomationand the write source;ImagePolicysemver: { range: ">=0.0.0" }(the tag the pipeline mints,<major>.<minor>.<count>, compares as semver —:sha-*tags are not semver and are correctly ignored,:latestis never published); the clone-keyVaultStaticSecretand the namedpath "secret/data/ci/milner"policy widen withCLONE_KEY_PATH=secret/ci/milner; the KEDAScaledJob(maxReplicaCount: 1,backoffLimit: 0,queueName: annia.dispatch, thessa: Mergeannotation, the pause-lever criteria);securityContextunset (anniaisbaseline, the image is root); the hand-runflux-write-deploy-keyprerequisite inOPERATIONS.md. The verification list's/nix,/workspace,/root/.piline becomes/nix(the merged store) and/root/.pi(the synced folder), with/workspacecarrying no claim.Dependencies
/root/.pi, and by the layered-store issue for what/nixis.tepichord referenced this issue2026-09-29 17:08:05 -07:00