fa4deac87745e8ae58bfa76f3c1ce75a179dabfc
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cec550045b |
feat(storage): the bulk tier becomes real — static NFS PVs, authored once per environment
The `bulk` class has existed since Phase 0 as a deliberate stub: the StorageClass named an NFS
CSI driver that is not installed and a server/share of REPLACE-ME, so plex, sonarr, radarr and
qbittorrent all carry a comment saying "attach the library once OQ-2 is resolved". This is that
resolution — for the case that actually exists here, which is NOT the case the stub anticipated.
The stub assumed DYNAMIC provisioning: ask a CSI driver for a bulk volume and get a fresh empty
subdirectory. That is the wrong shape for this platform's bulk data. The media library is 8.33 TiB
that already exists, on ZFS datasets on the Proxmox host, with live non-k3s consumers. Nothing
needs provisioning; existing datasets need ATTACHING. So a bulk storage entry now renders a
STATIC, pre-bound NFS PersistentVolume plus its PVC, and needs no CSI driver at all (the in-tree
NFS volume source is not deprecated and has no CSI migration).
Schema 1.4.0 adds, on a storage entry:
export absolute NFS export path; REQUIRED when class is bulk, FORBIDDEN otherwise (an `if`
in the schema, so a bulk entry cannot be authored without saying what it attaches).
subPath mount a subtree instead of the volume root — any class.
readOnly any class; on bulk it also sets the PV's nfs.readOnly, so the mount really is `ro`.
The SERVER is deliberately NOT a schema field. It is the `${BULK_NFS_SERVER}` cluster-vars knob,
because dev and prod reach the SAME storage host by different addresses and R-ENV-02 says the
service definition is authored once. A ServiceDefinition says WHICH dataset; the overlay says
where the host is.
Three decisions worth the reader's time, argued in full in the r_bulk_pvs docstring:
* PV/PVC, not an inline `nfs:` pod volume. Inline is fewer objects and would work. It cannot
express `persistentVolumeReclaimPolicy: Retain`, which is the one property most worth having
in git for 8.33 TiB of data with consumers outside this cluster; it puts read-only enforcement
one Deployment edit away from silent removal; and it is invisible to `kubectl get pv,pvc`,
which is where an operator looks for attached external storage.
* storageClassName: "" on both ends, NOT the `bulk` StorageClass. Explicitly empty is the
canonical marker for a statically provisioned volume. It also sidesteps a trap: a
StorageClass's `provisioner` is IMMUTABLE, so re-pointing the deployed `bulk` stub would make
Flux fail on that object forever.
* Access mode is always `…Many` and follows the read/write posture — ReadOnlyMany for a reader,
ReadWriteMany for a writer. One export is consumed by several namespaces AND several nodes at
once, which is exactly what ReadWriteOnce cannot express; which `Many` it is then makes the
intent legible in `kubectl get pv`.
mountOptions are a platform decision held in the renderer rather than a per-service field:
nfsvers=4.1,hard,noatime,timeo=600,retrans=2. NFSv4 carries mount and lock in-protocol, so the
only port that must be reachable is tcp/2049 — no rpcbind/111, mountd or statd. That is a
firewall decision as much as a storage one. `hard` over `soft` because a soft mount converts a
server hiccup into a short read handed to the application.
The contract doc records the trap that will otherwise be discovered in production: fsGroup is a
NO-OP on an NFS volume (the in-tree plugin does no ownership management), so a writable bulk mount
requires the container's own uid/gid to suit the export's ownership. A mount that succeeds and
then cannot write is this tier's characteristic failure.
Every pre-existing golden renders byte-identical: the new keys are emitted only when declared, and
`readOnly` defaults to False. render-golden: 10/10. selftest: PASS.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
c8c1d2935d |
feat(dns): per-environment BASE_DOMAIN — prod moves to its own subdomain
INCIDENT FIX. Prod's external-dns DELETED EVERY ONE OF DEV'S PUBLIC DNS RECORDS. Both
clusters ran policy: sync against the SAME Cloudflare zone with the SAME implicit
txtOwnerId (`default`), so prod believed it owned dev's records, found no matching sources,
and removed them plus their TXT registry entries. Dev recovered ~30s after prod was scaled
to 0; verified restored against Cloudflare directly (my first check went through a pfSense
override and falsely showed healthy — local resolvers are not evidence about public DNS).
THE MISS WAS MINE, and it was a failure of imagination in how I briefed every agent. I put
"never touch dev" in brief after brief and enforced it — separate kubeconfigs, verified
--limit, sha-checked dev's kubeconfig before and after. Every one of those guards held. NONE
of them covered SHARED EXTERNAL RESOURCES. Prod reconciling its OWN correctly-authored
manifests reached across a third party and destroyed dev's state. Cluster-to-cluster
isolation was verified; the dependency that mattered was cluster-to-Cloudflare.
Operator's architecture: dev -> *.dev.home.keithjackson.dev, prod ->
*.prod.home.keithjackson.dev, apex CNAME <svc>.home -> <svc>.prod.home.
PHASE 1 ONLY (this commit): prod moves to its own subdomain. DEV IS NOT MIGRATED and its
effective hostname stays home.keithjackson.dev, so dev renders byte-identically and nothing
live moves. That alone ends the collision and unblocks prod. Phase 2 (dev -> dev.home. +
apex CNAMEs) is deferred to when the operator is present: it relocates Keycloak's issuer and
12 clients' redirect URIs, forces a rolling k3s restart and kubectl SSO re-issue for every
user, and moves gitea.home. — which is BOTH the GitOps source and the current push target.
BASE_DOMAIN WAS A DEAD KNOB — it existed in cluster-vars but the status report flagged it as
referenced by nothing, because hostnames were hardcoded throughout. Now real: 141 refs.
Searched the AUTHORITATIVE reconciled set by resolving each Flux spec.path transitively via
conformance/lib/status.py — the same resolver the L0 gate uses, so "what Flux reconciles"
has one definition. 185 occurrences across 47 files in 154 reconciled files. Tokenized:
Gateway listeners, the wildcard Certificate, coredns-custom, external-dns, Keycloak
instance/realm (26 redirect URIs), oauth2-proxy, Grafana OIDC, all HTTPRoutes, Homepage's 15
links, 30 runbook_urls, unifi cloud-init, render.py's ZONE constant (which drove
--oidc-issuer-url/--cookie-domain/--whitelist-domain on every forward-auth sidecar) and 11
catalog/ ServiceDefinitions re-rendered canonically. Left alone: 43 comment/prose
occurrences and non-reconciled paths.
TWO THAT CANNOT BE TOKENIZED, flagged not forced:
- headlamp's OIDC URLs live inside a SOPS-encrypted Secret; re-encrypting rewrites the MAC
over every value and the OFFLINE harness cannot verify it (kustomize does not decrypt),
so an error would break live-dev SSO with no pre-flight proof. Prod needs its own copy
anyway — client secrets are realm-scoped.
- kube-apiserver --oidc-issuer-url is a k3s SERVER FLAG, not a manifest, so Flux can never
reach it. Done on the inventory seam instead (k3s_base_domain -> k3s_oidc_issuer_url,
overridden in group_vars/k3s_prod). Verified per host: dev keeps the default, prod gets
prod.home.
EXTERNAL-DNS OWNER, the actual incident cause: txtOwnerId is now explicit per environment —
dev renders exactly `default` (the chart default it was already using, so its existing
records stay recognised) and prod gets `prod`. The BASE default is a NON-COLLIDING SENTINEL,
deliberately not `default`, so a future environment that forgets the knob owns nothing and
can delete nothing rather than silently inheriting dev's identity.
I OVERRODE ONE AGENT DECISION against our own prior finding: it narrowed prod's
domainFilters to prod.home.keithjackson.dev as defence in depth. FINDING F-DNS1
(DECISIONS-LOG 2026-08-17) already proved a subdomain filter DROPS THE PARENT ZONE —
ExternalDNS generates the record then skips it with "no hosted zone matching record DNS
Name". Prod would have published NOTHING. Set to the ZONE (keithjackson.dev) like dev.
Isolation comes from the subdomain split plus txtOwnerId, which is what the incident
actually needed and what a domain filter cannot provide.
DEV PROVEN UNCHANGED: 11 of 12 substituting paths byte-identical; the 12th differs by
exactly one line, `+ txtOwnerId: default` — the value dev was already using implicitly.
POSITIVE CONTROLS confirm the harness is not blind: perturbing BASE_DOMAIN moves 10 of 12
hashes (the 2 that don't provably contain no hostname), perturbing the owner id moves
exactly 1. Prod's rendered output has ZERO home.keithjackson.dev references outside prod.,
and its cert renders *.prod.home.keithjackson.dev — no overlap with dev's.
L0 substitution assertions 106 -> 254; BASE_DOMAIN is no longer flagged dead. Gate PASS.
PROD REMAINS SUSPENDED (platform + 10 downstream tiers) pending review; nothing applied.
Serves: R-ENV-02, R-DNS-01, R-DNS-02, R-DNS-03, R-TLS-01.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
28498f1099 |
fix(apps): semaphore fsGroup — schema 1.3.0 podSecurityContext for non-root PVC write
The semaphoreui/semaphore image runs as non-root UID 1001 / GID 0 and writes its generated config.json into the config dir on boot. A mounted Longhorn PVC is root:root, so UID 1001 got "permission denied" and the pod crash-looped. First-class the fix into the render contract (same mechanism the retired gitea-bootstrap-patch was replaced by): schema 1.3.0 adds an optional pod-level `podSecurityContext`, emitted verbatim onto spec.template.spec.securityContext by render.py. Absent => every other app renders byte-identical (verified). semaphore declares fsGroup=1001 (kubelet group-owns the PVC + adds the supplemental group so UID 1001 can write) plus runAsUser/runAsGroup/runAsNonRoot pinning the image identity. render.py selftest extended (verbatim render + absence). L0 (schema+kyverno 17/17), L1 3/3, lint all green. Co-Authored-By: Claude Opus 4.8 <[email protected]> |
||
|
|
c8dd4f3f9d |
feat(catalog): add SD env/envFrom/lifecycle; retire gitea bootstrap patch
Extend the ServiceDefinition contract with optional per-container `env`, `envFrom`, and `lifecycle` fields (k8s EnvVar / EnvFromSource / Lifecycle shapes) so headless-install / DB-backed apps configure themselves through the SD instead of a supplementary kustomize patch. - schema: bump 1.1.0 -> 1.2.0; add optional env/envFrom/lifecycle to the container kind (literal + secretKeyRef/configMapKeyRef; secretRef/ configMapRef; postStart/preStop exec via $defs/execHandler). Existing fields unchanged; all fields optional -> absent renders byte-identical. - render.py: emit env/envFrom/lifecycle verbatim onto the Deployment app container when present; --selftest coverage for the new fields. - catalog/gitea.yaml: fold the headless-install env (INSTALL_LOCK, fixed SECRET_KEY via secretKeyRef, DOMAIN/ROOT_URL, actions, break-glass admin creds) + the idempotent postStart break-glass admin bootstrap inline. - apps/gitea.yaml: re-rendered with env/lifecycle inline (effective pod spec provably equivalent to base+patch). - DELETE apps/gitea-bootstrap-patch.yaml + drop it from apps/kustomization patches. gitea-bootstrap-secret.sops.yaml kept (still sourced by the env). Proves the renderer feature by retiring the known TODO; unblocks env + DB onboarding for nextcloud/semaphore/unifi/minecraft. All other rendered apps byte-identical. Gates: selftest PASS, l0 17/17 + schema, l1 goldens, lint. Co-Authored-By: Claude Opus 4.8 <[email protected]> |
||
|
|
73c4475d34 |
fix(gitea): mount PVC at /data + headless-install bootstrap (Phase 6)
Two defects blocked in-cluster Gitea from becoming the Flux source: 1. Storage mountPath: the renderer hard-coded /data/<name>, so Gitea's PVC mounted at /data/data while Gitea writes all state to /data — real repos/DB landed on the ephemeral rootfs. Add an OPTIONAL `mountPath` to the storage schema + render.py (default stays /data/<name>, back-compat). catalog/gitea.yaml sets data.mountPath: /data. Only gitea.yaml's rendered mount changes; every other app renders byte-identical. 2. Headless install: the instance was un-installed (INSTALL_LOCK=false, ephemeral SECRET_KEY, no admin, API 404). A Gitea-only supplementary manifest (apps/gitea-bootstrap-patch.yaml) patches the rendered Deployment with GITEA__security__INSTALL_LOCK=true, a FIXED SECRET_KEY from a persistent SOPS secret (apps/gitea-bootstrap-secret.sops.yaml), ROOT_URL/DOMAIN, and a postStart hook that idempotently creates a break-glass local admin. State persists on the now-correct /data Longhorn PVC. The env/lifecycle wiring is a supplementary kustomize patch because the ServiceDefinition schema/renderer does not yet express env/envFrom/lifecycle — noted as a renderer follow-up (TODO in the patch). Co-Authored-By: Claude Opus 4.8 <[email protected]> |
||
|
|
aafb4ca378 |
docs(vpn-egress): reflect ADR-0015 node-tunnel egress, retire gluetun narrative
The in-cluster Gluetun gateway is retired; VPN egress is now a node-level host OpenVPN split-tunnel (Ansible role vpn_egress_gateway) steered by a Cilium Egress Gateway policy with a host nftables kill-switch (ADR-0015). Credentials moved host-side to the Ansible vault. Update docs/comments only: - platform/vpn-egress/README.md: rewrite for ADR-0015 (live + conformance-green); drop the resolved "option 1 not viable with Gluetun" finding to a brief history note. - platform/kustomization.yaml: Phase 3 comment -> node tunnel + Cilium steering. - platform/kyverno/.../require-egress-class-label.yaml: ADR-0013 -> ADR-0015 steering. - schemas/servicedefinition.schema.json: egress=vpn description -> node tunnel + Cilium. - clusters/base/cluster-vars.yaml: reframe PIA_REGION / CLUSTER_POD_CIDR WHY comments to ADR-0015 (Cilium excludedCIDRs); both keys retained (still referenced/documented). - platform/vpn-egress/namespace.yaml: no privileged gateway pod under ADR-0015. - pia-credentials.sops.yaml / pia-custom-ovpn.sops.yaml: comment headers only note they are now UNUSED (creds host-side), retained pending cleanup; payloads untouched. No runtime manifest behavior, alert exprs, kustomization resources, or SOPS payloads changed. No spec-repo edits. Co-Authored-By: Claude Opus 4.8 <[email protected]> |
||
|
|
8c1368977e |
Phase 0 clarification follow-ups + reconcile CI integration seams
Adopt the spec's Phase-0 clarifications (spec now at pin a7fc19f) into the impl: - schema v1.1: enforce exposure:public => auth.mode!=none (positive+negative tested). - Kyverno: add longhorn=>size and longhorn=>NOT-reacquirable invariants (R-STATE-02/03). - Ratify the homelab.keithjackson.dev/* label-key convention (now in spec docs/03). - R-TEST-06 spec-immutability gate: ci/SPEC_PIN + ci/spec-immutability.sh + gate.sh stage + Makefile target + a Gitea job (checks spec repo clean & at pin). - touched-requirements.sh: conformance tags are the authoritative source. Reconcile 3 CI seams the CI worker flagged (gate was RED, now GREEN): - create schemas/validate.sh (SCHEMA_VALIDATE_CMD entrypoint) — L0 schema gate runs. - fix l1.sh/conformance.sh to run.sh's real interface (fixed _artifacts/ paths; no --junit/--coverage) and collect artifacts. - exec bits on new scripts. make -C ci gate => PASS (l0 skips locally w/o kyverno binary; green in CI). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
eccc13bb3c |
T-0.3 ServiceDefinition schema + renderer contract + examples (R-GITOPS-02)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
253b6600f4 |
Initial skeleton: home-network-k3s implementation repo
Phase 0 foundation shell (read-only spec at ../home-network-k3s). Top-level tree + rules; workstream agents fill their subtrees on feature branches. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |