jakman85andClaude Opus 5 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]>
2026-09-07 23:45:09 -05:00
2026-09-07 23:05:59 -05:00

home-network-k3s-impl

Implementation of the home k3s + Longhorn platform. This repo holds the reconciled artifacts (Flux config, Kustomize overlays, KubeVirt/Helm manifests, ServiceDefinitions, Kyverno policies, Chainsaw conformance tests, CI). It is driven by — and subordinate to — the read-only specification at ../home-network-k3s.

Rules (from the spec — non-negotiable)

  • The spec is read-only. Never modify anything under ../home-network-k3s. Implement against it; escalate spec problems, don't edit them (spec AGENTS.md §11, R-TEST-06).
  • Done = a green, reproducible, requirement-tagged conformance test — never "merged," never a manual demo (spec docs/14, agents/definition-of-done.md).
  • No work on main. Every change is a feature branch merged into main (spec CONTRIBUTING.md).
  • No plaintext secrets — SOPS-encrypted only (spec docs/07).
  • Phase discipline — only build what the active roadmap phase authorizes (spec roadmap/phases.md).

Layout

infra/          node provisioning: OpenTofu (Proxmox VE) + cloud-init + Ansible, and the
                pinned dev container that carries the whole toolchain
bootstrap/      one-time cluster bootstrap: Flux install, SOPS/age key, host Gitea
platform/       platform CONTROLLERS (Flux HelmReleases / operator bases): kyverno, cilium,
                longhorn, external-snapshotter, cert-manager, externaldns, gateway, coredns,
                vpn-egress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
platform-config/ platform CONFIGS — the custom resources of the CRDs installed above
                (ClusterIssuers, Gateway, Kyverno policies, KubeVirt/CDI CRs, Keycloak CRs).
                Split out because CRs and the CRDs they use cannot apply in one pass.
apps/           application tier, reconciled as dependency-ordered WAVES:
                  wave-0/ wave-1/ wave-2/   rendered container apps (see DESIGN-waves.md)
                  vms/                      KubeVirt VirtualMachines (non-gating tier)
catalog/        ServiceDefinition entries (the onboarding surface) + render.py
schemas/        ServiceDefinition JSON Schema + renderer contract
clusters/       Flux entrypoints + dev/ and prod/ overlays
conformance/    Chainsaw behavior/conformance suite + harness + coverage report
ci/             the gate as scripts (lint · schema · Kyverno L0 · L1 render · decision)
.gitea/         Gitea Actions CI running that gate on an in-cluster runner

Status

Roadmap phases 0–6 are delivered and deployed on the dev cluster; Phase 7 (prod) is not open — clusters/prod is a design-only overlay.

Phase Delivered
0 Foundations ServiceDefinition schema + renderer, Kyverno L0 gate, conformance harness, CI
1 Cluster bring-up 3-node k3s HA on Proxmox VE (192.168.1.50/.51/.52), Cilium, Flux, SOPS
2 Platform core Cilium · Longhorn · cert-manager (LE-prod wildcard) · ExternalDNS · Gateway API · split-horizon CoreDNS
3 Egress / VPN ADR-0015 node-level OpenVPN split-tunnel + Cilium egress-gateway steering + host kill-switch
4 Observability kube-prometheus-stack · Loki · Alloy · Alertmanager. Velero still blocked on OQ-BKP; image-automation authored but disabled
5 IaaS KubeVirt + CDI on real nested KVM; live-migratable RWX VM storage. The UniFi VM runs, but UniFi OS Server is not yet installed in the guest
6 Identity + CI Keycloak (Operator + CNPG) · oauth2-proxy forward-auth · Grafana OIDC · kubectl OIDC SSO with group→RBAC · in-cluster Gitea Actions merge gate

Conformance: 36 / 51 requirements covered (70.6%), 15 gaps, 1 safety-critical gap (R-STATE-02, Longhorn node-loss). Regenerate with ./conformance/run.sh --coverage-only; the full report is conformance/_artifacts/coverage/coverage.md.

The running narrative — decisions, root causes, incidents, follow-ups — is DECISIONS-LOG.md. It, not this table, is the authoritative record.

Working on this repo

Everything the gate needs is baked into the pinned dev container — do not install the toolchain on the host:

infra/devcontainer/dev.sh build            # once
infra/devcontainer/dev.sh run make -C ci gate   # the same gate CI runs
infra/devcontainer/dev.sh                  # interactive shell (sops, flux, kubectl, tofu, ansible)

The container mounts the repo at /work, your age keyring, and the sibling spec repo read-only so the spec-immutability check can actually run. See infra/devcontainer/README.md.

A green run prints MERGE GATE: PASS. The L2/L3 conformance layers stay SKIPPED unless CLUSTER_AVAILABLE=true — the static gate never pretends to have run them.

S
Description
No description provided
Readme
7.2 MiB
0 Stars 2 Watchers 0 Forks
Languages
Shell 61.4%
Python 32.3%
Jinja 2.9%
Dockerfile 2.2%
HCL 1.1%
Other 0.1%