From 8afac39d0350c08b36019a39f7e278a94c507476 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 26 Jul 2026 02:13:56 -0700 Subject: [PATCH] Import manifests reverse-engineered from live cluster Captured 2026-07-26 from rpi-master (k3s v1.30.3) and cleaned of runtime fields. Six apps as per-app kustomizations: vaultwarden, searxng, home-assistant, nginx, pihole(+unbound), unified-streaming. Intentional divergences from live state: - pihole WEBPASSWORD and USP_LICENSE_KEY moved from inline plaintext env to secretKeyRef (secrets gitignored; templates in secret.example.yaml) - HA ingress defaultBackend fixed (pointed at nonexistent service) - unifiedstreaming-svc kept as ClusterIP (LoadBalancer could never bind port 80 behind svclb-traefik) Validated against the live cluster with kubectl apply --dry-run=server: no immutable-field conflicts; one-time kubectl replace procedure for the two env->secretKeyRef migrations documented in README. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + README.md | 100 ++++++++++++++++++++- apps/home-assistant/deployment.yaml | 74 +++++++++++++++ apps/home-assistant/ingress.yaml | 37 ++++++++ apps/home-assistant/kustomization.yaml | 8 ++ apps/home-assistant/namespace.yaml | 4 + apps/home-assistant/service.yaml | 15 ++++ apps/home-assistant/storage.yaml | 30 +++++++ apps/nginx/deployment.yaml | 59 ++++++++++++ apps/nginx/ingress.yaml | 28 ++++++ apps/nginx/kustomization.yaml | 8 ++ apps/nginx/namespace.yaml | 4 + apps/nginx/service.yaml | 14 +++ apps/nginx/storage.yaml | 35 ++++++++ apps/pihole/deployment-pihole.yaml | 86 ++++++++++++++++++ apps/pihole/deployment-unbound.yaml | 54 +++++++++++ apps/pihole/kustomization.yaml | 10 +++ apps/pihole/namespace.yaml | 4 + apps/pihole/secret.example.yaml | 13 +++ apps/pihole/service.yaml | 53 +++++++++++ apps/pihole/storage.yaml | 55 ++++++++++++ apps/searxng/deployment.yaml | 78 ++++++++++++++++ apps/searxng/ingress.yaml | 33 +++++++ apps/searxng/kustomization.yaml | 8 ++ apps/searxng/secret.example.yaml | 14 +++ apps/searxng/service.yaml | 18 ++++ apps/unified-streaming/deployment.yaml | 69 ++++++++++++++ apps/unified-streaming/ingress.yaml | 18 ++++ apps/unified-streaming/kustomization.yaml | 10 +++ apps/unified-streaming/namespace.yaml | 4 + apps/unified-streaming/secret.example.yaml | 16 ++++ apps/unified-streaming/service.yaml | 18 ++++ apps/unified-streaming/storage.yaml | 32 +++++++ apps/vaultwarden/deployment.yaml | 75 ++++++++++++++++ apps/vaultwarden/ingress.yaml | 23 +++++ apps/vaultwarden/kustomization.yaml | 8 ++ apps/vaultwarden/namespace.yaml | 4 + apps/vaultwarden/service.yaml | 19 ++++ apps/vaultwarden/storage.yaml | 30 +++++++ kustomization.yaml | 9 ++ 40 files changed, 1179 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 apps/home-assistant/deployment.yaml create mode 100644 apps/home-assistant/ingress.yaml create mode 100644 apps/home-assistant/kustomization.yaml create mode 100644 apps/home-assistant/namespace.yaml create mode 100644 apps/home-assistant/service.yaml create mode 100644 apps/home-assistant/storage.yaml create mode 100644 apps/nginx/deployment.yaml create mode 100644 apps/nginx/ingress.yaml create mode 100644 apps/nginx/kustomization.yaml create mode 100644 apps/nginx/namespace.yaml create mode 100644 apps/nginx/service.yaml create mode 100644 apps/nginx/storage.yaml create mode 100644 apps/pihole/deployment-pihole.yaml create mode 100644 apps/pihole/deployment-unbound.yaml create mode 100644 apps/pihole/kustomization.yaml create mode 100644 apps/pihole/namespace.yaml create mode 100644 apps/pihole/secret.example.yaml create mode 100644 apps/pihole/service.yaml create mode 100644 apps/pihole/storage.yaml create mode 100644 apps/searxng/deployment.yaml create mode 100644 apps/searxng/ingress.yaml create mode 100644 apps/searxng/kustomization.yaml create mode 100644 apps/searxng/secret.example.yaml create mode 100644 apps/searxng/service.yaml create mode 100644 apps/unified-streaming/deployment.yaml create mode 100644 apps/unified-streaming/ingress.yaml create mode 100644 apps/unified-streaming/kustomization.yaml create mode 100644 apps/unified-streaming/namespace.yaml create mode 100644 apps/unified-streaming/secret.example.yaml create mode 100644 apps/unified-streaming/service.yaml create mode 100644 apps/unified-streaming/storage.yaml create mode 100644 apps/vaultwarden/deployment.yaml create mode 100644 apps/vaultwarden/ingress.yaml create mode 100644 apps/vaultwarden/kustomization.yaml create mode 100644 apps/vaultwarden/namespace.yaml create mode 100644 apps/vaultwarden/service.yaml create mode 100644 apps/vaultwarden/storage.yaml create mode 100644 kustomization.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e702956 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Real secrets never go in git — only *.example.yaml templates do. +apps/**/secret.yaml +*.secret.yaml diff --git a/README.md b/README.md index 00f02c5..6c4098d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ # homelab-k3s -k3s deployments and change management for homelab env. \ No newline at end of file +k3s deployments and change management for the homelab Raspberry Pi cluster +(rpi-master + rpi-worker-1/2/3, k3s v1.30.3, flannel/traefik/klipper-lb +defaults). + +Manifests were reverse-engineered from the live cluster on 2026-07-26 and +cleaned of runtime fields. Except where a file header says **DIVERGENCE**, +they are faithful to what was running. + +## Layout + +Each app under `apps/` is a self-contained kustomization +(namespace + PV/PVC + deployment + service + ingress): + +| App | Namespace | Hostname(s) | +|---|---|---| +| vaultwarden | bitwarden | pm.wittyoneoff.com | +| searxng | default | searxng.wittyoneoff.com, origin-searxng.wittyoneoff.com | +| home-assistant | home-assistant | ha.wittyoneoff.com, ha-origin.wittyoneoff.com | +| nginx | nginx | www.wittyoneoff.com, www.ramonaajj.com | +| pihole (+unbound) | pihole | admin on :8000 via klipper-lb, DNS on :53 | +| unified-streaming | unified-streaming | unified.wittyoneoff.com | + +Not in this repo: socktop (own repo + pipeline), rancher/monitoring/logging +(rancher-managed), traefik (k3s packaged component). + +## Applying + +```sh +kubectl apply -k . # everything +kubectl apply -k apps/home-assistant # one app +``` + +### One-time secret bootstrap (per cluster) + +Three apps read secrets that are NOT in git. Each app dir has a +`secret.example.yaml`; copy to `secret.yaml` (gitignored), fill in, apply. + +| Secret | Namespace | Used for | +|---|---|---| +| `pihole-admin` | pihole | pi-hole admin `WEBPASSWORD` | +| `usp-license` | unified-streaming | `USP_LICENSE_KEY` | +| `searxng-1723974683-config` | default | searxng `settings.yml` | + +**One-time migration (pihole + unified-streaming):** the live cluster (as of +2026-07-26) has WEBPASSWORD / USP_LICENSE_KEY as inline plaintext `value`s. +`kubectl apply` (client- or server-side) cannot patch an env var from `value` +to `valueFrom` — the API rejects the merged object. Validated procedure: + +```sh +# 1. create the two secrets (see secret.example.yaml in each app dir) +# 2. one-time replace (restarts the pods): +kubectl replace -f apps/pihole/deployment-pihole.yaml +kubectl replace -f apps/unified-streaming/deployment.yaml +``` + +After that, `kubectl apply -k .` works cleanly for everything (verified with +`--dry-run=server` against the live cluster, 2026-07-26 — zero immutable-field +conflicts). + +## Cluster assumptions (state that lives outside these manifests) + +- **Node labels** — scheduling relies on them: workers carry `cpu=arm` + (all nodes do), the control-plane node carries `controller=true` + (most apps use `controller NotIn (true)` to stay off the master), pis carry + `model=raspi` (pihole/unbound prefer it). Re-label with: + `kubectl label node cpu=arm model=raspi` / + `kubectl label node rpi-master controller=true`. +- **GlusterFS** — every PV is `hostPath` under `/mnt/gvolume0/…`, which is the + gluster volume mounted identically on all nodes. The mount must exist before + pods schedule; PVs bind by explicit `volumeName` + `storageClassName: ""`. +- **Ingress/LB** — traefik (k3s default) terminates all HTTP on 80/443 via + svclb on every node. Because of that, no other LoadBalancer service can + claim host port 80 (that's why unifiedstreaming-svc is ClusterIP). +- **Home Assistant config** — lives on the PV, not in git. + `http.trusted_proxies` must include `10.42.0.0/16` (the cluster pod CIDR) + or HA 400s everything the moment traefik reschedules to another node. + +## Known quirks (faithful to live state, fix at leisure) + +- Most images are `:latest`/`:stable` — deploys are not reproducible until + pinned. vaultwarden is pinned (1.35.4). +- searxng keeps its helm-generated name suffix (`searxng-1723974683`) because + deployment selectors are immutable; its helm release record still exists in + the cluster — don't run `helm upgrade/uninstall` on it, this repo is the + source of truth now. +- searxng's `TZ=America/Los Angeles` (missing underscore) is invalid tzdata — + container falls back to UTC. +- vaultwarden and home-assistant carry helm-chart-era label/name shapes but + their release records are gone; they are plain manifests now. +- pihole runs `privileged: true` with `PIHOLE_UID=0`. + +## History + +- 2026-07-25: cluster outage (rpi-master undervoltage → CNI cache corruption). + Recovery runbook in the Notes vault: + `rpi-master_undervoltage_CNI_corruption_incident-2026-07-25.md`. +- 2026-07-26: initial import of manifests from live cluster; HA ingress + defaultBackend fixed; unifiedstreaming-svc LoadBalancer→ClusterIP; + pihole/USP secrets moved to secretKeyRef (pending first apply). diff --git a/apps/home-assistant/deployment.yaml b/apps/home-assistant/deployment.yaml new file mode 100644 index 0000000..b4dbb29 --- /dev/null +++ b/apps/home-assistant/deployment.yaml @@ -0,0 +1,74 @@ +# Originally installed via helm chart home-assistant-13.3.0 (release record no +# longer in cluster); managed as a raw manifest since. Selector labels are +# immutable — do not change them. +# NOTE: /config/configuration.yaml (on the PV, not in git) must keep +# http.trusted_proxies: [10.42.0.0/16] so traefik can reach HA from any node. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: home-assistant + namespace: home-assistant + labels: + app.kubernetes.io/instance: home-assistant + app.kubernetes.io/name: home-assistant +spec: + replicas: 1 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/instance: home-assistant + app.kubernetes.io/name: home-assistant + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/instance: home-assistant + app.kubernetes.io/name: home-assistant + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: controller + operator: NotIn + values: + - "true" + containers: + - name: home-assistant + image: ghcr.io/home-assistant/home-assistant:stable + imagePullPolicy: Always + env: + - name: TZ + value: UTC + ports: + - name: http + containerPort: 8123 + protocol: TCP + startupProbe: + tcpSocket: + port: 8123 + failureThreshold: 30 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: 8123 + failureThreshold: 3 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: 8123 + failureThreshold: 3 + periodSeconds: 10 + volumeMounts: + - name: config + mountPath: /config + volumes: + - name: config + persistentVolumeClaim: + claimName: home-assistant diff --git a/apps/home-assistant/ingress.yaml b/apps/home-assistant/ingress.yaml new file mode 100644 index 0000000..1918672 --- /dev/null +++ b/apps/home-assistant/ingress.yaml @@ -0,0 +1,37 @@ +# NOTE: the live object's defaultBackend pointed at a service named +# "home-assistant", which does not exist (the real service is +# "home-assistant-svc"). Fixed here — this is the one intentional divergence +# from the cluster state captured 2026-07-26. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: home-assistant + namespace: home-assistant +spec: + ingressClassName: traefik + defaultBackend: + service: + name: home-assistant-svc + port: + number: 80 + rules: + - host: ha.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: home-assistant-svc + port: + number: 80 + - host: ha-origin.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: home-assistant-svc + port: + number: 80 diff --git a/apps/home-assistant/kustomization.yaml b/apps/home-assistant/kustomization.yaml new file mode 100644 index 0000000..ea3b943 --- /dev/null +++ b/apps/home-assistant/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - storage.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/apps/home-assistant/namespace.yaml b/apps/home-assistant/namespace.yaml new file mode 100644 index 0000000..7c1d06d --- /dev/null +++ b/apps/home-assistant/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: home-assistant diff --git a/apps/home-assistant/service.yaml b/apps/home-assistant/service.yaml new file mode 100644 index 0000000..96ea680 --- /dev/null +++ b/apps/home-assistant/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: home-assistant-svc + namespace: home-assistant +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: home-assistant + app.kubernetes.io/name: home-assistant + ports: + - name: http + port: 80 + targetPort: 8123 + protocol: TCP diff --git a/apps/home-assistant/storage.yaml b/apps/home-assistant/storage.yaml new file mode 100644 index 0000000..f6460d5 --- /dev/null +++ b/apps/home-assistant/storage.yaml @@ -0,0 +1,30 @@ +# /config lives on GlusterFS (/mnt/gvolume0 mounted on every node). +# Known risk: HA recorder DB on gluster + non-atomic renames → occasional +# .storage write errors. See homelab notes. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: home-assistant-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/home-assistant +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: home-assistant + namespace: home-assistant +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: "" + volumeName: home-assistant-pv diff --git a/apps/nginx/deployment.yaml b/apps/nginx/deployment.yaml new file mode 100644 index 0000000..4fff309 --- /dev/null +++ b/apps/nginx/deployment.yaml @@ -0,0 +1,59 @@ +# Created via Rancher originally — the workload.user.cattle.io selector label +# is immutable on the live object; do not change it. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx + namespace: nginx + labels: + workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx +spec: + replicas: 3 + selector: + matchLabels: + workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + template: + metadata: + labels: + workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: controller + operator: NotIn + values: + - "true" + containers: + - name: nginx + image: nginx:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + protocol: TCP + volumeMounts: + - name: vol-vnbxq + mountPath: /usr/share/nginx/html + subPath: www + - name: vol-vnbxq + mountPath: /etc/nginx/conf + subPath: conf + - name: vol-vnbxq + mountPath: /etc/nginx/conf.d + subPath: conf.d + volumes: + - name: vol-vnbxq + persistentVolumeClaim: + claimName: nginx diff --git a/apps/nginx/ingress.yaml b/apps/nginx/ingress.yaml new file mode 100644 index 0000000..9012ad4 --- /dev/null +++ b/apps/nginx/ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: nginx + namespace: nginx +spec: + ingressClassName: traefik + rules: + - host: www.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: nginx + port: + number: 80 + - host: www.ramonaajj.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: nginx + port: + number: 80 diff --git a/apps/nginx/kustomization.yaml b/apps/nginx/kustomization.yaml new file mode 100644 index 0000000..ea3b943 --- /dev/null +++ b/apps/nginx/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - storage.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/apps/nginx/namespace.yaml b/apps/nginx/namespace.yaml new file mode 100644 index 0000000..625789d --- /dev/null +++ b/apps/nginx/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: nginx diff --git a/apps/nginx/service.yaml b/apps/nginx/service.yaml new file mode 100644 index 0000000..f75e938 --- /dev/null +++ b/apps/nginx/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx + namespace: nginx +spec: + type: ClusterIP + selector: + workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP diff --git a/apps/nginx/storage.yaml b/apps/nginx/storage.yaml new file mode 100644 index 0000000..d65ec5b --- /dev/null +++ b/apps/nginx/storage.yaml @@ -0,0 +1,35 @@ +# Static site content + nginx config live on GlusterFS: +# /mnt/gvolume0/nginx/www → /usr/share/nginx/html +# /mnt/gvolume0/nginx/conf → /etc/nginx/conf +# /mnt/gvolume0/nginx/conf.d → /etc/nginx/conf.d +apiVersion: v1 +kind: PersistentVolume +metadata: + name: nginx-gvolume0 +spec: + capacity: + storage: 50Gi + accessModes: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/nginx +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: nginx + namespace: nginx +spec: + accessModes: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + resources: + requests: + storage: 50Gi + storageClassName: "" + volumeName: nginx-gvolume0 diff --git a/apps/pihole/deployment-pihole.yaml b/apps/pihole/deployment-pihole.yaml new file mode 100644 index 0000000..8723860 --- /dev/null +++ b/apps/pihole/deployment-pihole.yaml @@ -0,0 +1,86 @@ +# DIVERGENCE from live state (intentional): the live deployment has WEBPASSWORD +# as a plaintext env value. Here it comes from the `pihole-admin` secret — +# create it (see secret.example.yaml) BEFORE applying this, or the pod will +# fail with CreateContainerConfigError. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pihole + namespace: pihole + labels: + app: pihole +spec: + replicas: 1 + selector: + matchLabels: + app: pihole + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + template: + metadata: + labels: + app: pihole + spec: + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + preference: + matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: model + operator: In + values: + - raspi + containers: + - name: pihole + image: pihole/pihole:latest + imagePullPolicy: IfNotPresent + env: + - name: TZ + value: America/Los_Angeles + - name: WEBPASSWORD + valueFrom: + secretKeyRef: + name: pihole-admin + key: WEBPASSWORD + - name: PIHOLE_UID + value: "0" + ports: + - containerPort: 53 + protocol: TCP + - containerPort: 53 + protocol: UDP + - containerPort: 67 + protocol: UDP + - containerPort: 80 + protocol: TCP + - containerPort: 443 + protocol: TCP + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 2Gi + securityContext: + privileged: true + volumeMounts: + - name: gv-pihole + mountPath: /etc/pihole + - name: gv-pihole-dnsmasq-volume + mountPath: /etc/dnsmasq.d + volumes: + - name: gv-pihole + persistentVolumeClaim: + claimName: pihole-gvolume0 + - name: gv-pihole-dnsmasq-volume + persistentVolumeClaim: + claimName: pihole-dnsmasq-volume diff --git a/apps/pihole/deployment-unbound.yaml b/apps/pihole/deployment-unbound.yaml new file mode 100644 index 0000000..e6f92a4 --- /dev/null +++ b/apps/pihole/deployment-unbound.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unbound + namespace: pihole + labels: + app: unbound +spec: + replicas: 3 + selector: + matchLabels: + app: unbound + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + template: + metadata: + labels: + app: unbound + spec: + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + preference: + matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: model + operator: In + values: + - raspi + containers: + - name: unbound + image: docker.io/mvance/unbound-rpi:latest + imagePullPolicy: Always + ports: + - name: svc-53-udp + containerPort: 53 + protocol: UDP + - name: svc-53-tcp + containerPort: 53 + protocol: TCP + resources: + requests: + cpu: 250m + memory: 50Mi + limits: + cpu: "1" + memory: 1Gi diff --git a/apps/pihole/kustomization.yaml b/apps/pihole/kustomization.yaml new file mode 100644 index 0000000..c9a4c11 --- /dev/null +++ b/apps/pihole/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml +# (gitignored) and apply manually once per cluster. +resources: + - namespace.yaml + - storage.yaml + - deployment-pihole.yaml + - deployment-unbound.yaml + - service.yaml diff --git a/apps/pihole/namespace.yaml b/apps/pihole/namespace.yaml new file mode 100644 index 0000000..9693809 --- /dev/null +++ b/apps/pihole/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: pihole diff --git a/apps/pihole/secret.example.yaml b/apps/pihole/secret.example.yaml new file mode 100644 index 0000000..636b665 --- /dev/null +++ b/apps/pihole/secret.example.yaml @@ -0,0 +1,13 @@ +# Copy to secret.yaml (gitignored), set the real admin password, apply once: +# kubectl apply -f secret.yaml +# Or create directly without a file: +# kubectl create secret generic pihole-admin -n pihole \ +# --from-literal=WEBPASSWORD='...' +apiVersion: v1 +kind: Secret +metadata: + name: pihole-admin + namespace: pihole +type: Opaque +stringData: + WEBPASSWORD: CHANGE-ME diff --git a/apps/pihole/service.yaml b/apps/pihole/service.yaml new file mode 100644 index 0000000..15fc154 --- /dev/null +++ b/apps/pihole/service.yaml @@ -0,0 +1,53 @@ +# pihole-svc is type LoadBalancer → k3s klipper-lb (svclb) binds host ports +# 8000/53/67 on the nodes it schedules on. NodePorts are pinned so they +# survive re-creation. +apiVersion: v1 +kind: Service +metadata: + name: pihole-svc + namespace: pihole +spec: + type: LoadBalancer + externalTrafficPolicy: Cluster + selector: + app: pihole + ports: + - name: http-admin + port: 8000 + targetPort: 80 + nodePort: 31120 + protocol: TCP + - name: tcp-53 + port: 53 + targetPort: 53 + nodePort: 32580 + protocol: TCP + - name: udp-53 + port: 53 + targetPort: 53 + nodePort: 31281 + protocol: UDP + - name: udp-67 + port: 67 + targetPort: 67 + nodePort: 31025 + protocol: UDP +--- +apiVersion: v1 +kind: Service +metadata: + name: unbound-service + namespace: pihole +spec: + type: ClusterIP + selector: + app: unbound + ports: + - name: udp-53 + port: 53 + targetPort: 53 + protocol: UDP + - name: tcp-53 + port: 53 + targetPort: 53 + protocol: TCP diff --git a/apps/pihole/storage.yaml b/apps/pihole/storage.yaml new file mode 100644 index 0000000..b36437e --- /dev/null +++ b/apps/pihole/storage.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: pihole-gvolume0-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/pihole +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: pihole-dnsmasq-volume-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteOnce + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/pihole-dnsmasq-volume +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pihole-gvolume0 + namespace: pihole +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: "" + volumeName: pihole-gvolume0-pv +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pihole-dnsmasq-volume + namespace: pihole +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: "" + volumeName: pihole-dnsmasq-volume-pv diff --git a/apps/searxng/deployment.yaml b/apps/searxng/deployment.yaml new file mode 100644 index 0000000..3b0ddf0 --- /dev/null +++ b/apps/searxng/deployment.yaml @@ -0,0 +1,78 @@ +# Helm release searxng-1723974683 (chart searxng-1.0.0) in the *default* +# namespace — the release is still recorded in the cluster, so if you ever +# `helm upgrade` it, helm and this manifest will fight. Prefer retiring the +# helm release (helm delete --no-hooks would delete resources; instead just +# treat this repo as the source of truth and never run helm against it). +# Names keep the timestamped release suffix because selectors are immutable. +# NOTE (faithful to live state): TZ value "America/Los Angeles" is missing the +# underscore — an invalid tzdata name, so searxng falls back to UTC. Kept as-is; +# fix to America/Los_Angeles when convenient. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: searxng-1723974683 + namespace: default + labels: + app.kubernetes.io/instance: searxng-1723974683 + app.kubernetes.io/name: searxng +spec: + replicas: 1 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/instance: searxng-1723974683 + app.kubernetes.io/name: searxng + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + template: + metadata: + labels: + app.kubernetes.io/instance: searxng-1723974683 + app.kubernetes.io/name: searxng + spec: + containers: + - name: searxng-1723974683 + image: searxng/searxng:latest + imagePullPolicy: Always + env: + - name: AUTOCOMPLETE + value: google + - name: BASE_URL + value: http://searxng.wittyoneoff.com/ + - name: INSTANCE_NAME + value: witty-one-off-searxng + - name: TZ + value: America/Los Angeles + ports: + - name: http + containerPort: 8080 + protocol: TCP + startupProbe: + httpGet: + path: /healthz + port: 8080 + failureThreshold: 30 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + failureThreshold: 3 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + failureThreshold: 3 + periodSeconds: 10 + volumeMounts: + - name: searxng-config + mountPath: /etc/searxng/settings.yml + subPath: settings.yml + volumes: + - name: searxng-config + secret: + secretName: searxng-1723974683-config diff --git a/apps/searxng/ingress.yaml b/apps/searxng/ingress.yaml new file mode 100644 index 0000000..ecc2861 --- /dev/null +++ b/apps/searxng/ingress.yaml @@ -0,0 +1,33 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: searxng-ingress + namespace: default +spec: + ingressClassName: traefik + defaultBackend: + service: + name: searxng-1723974683 + port: + number: 8080 + rules: + - host: searxng.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: searxng-1723974683 + port: + number: 8080 + - host: origin-searxng.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: searxng-1723974683 + port: + number: 8080 diff --git a/apps/searxng/kustomization.yaml b/apps/searxng/kustomization.yaml new file mode 100644 index 0000000..c2e0678 --- /dev/null +++ b/apps/searxng/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml +# (gitignored) and apply manually once per cluster. +resources: + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/apps/searxng/secret.example.yaml b/apps/searxng/secret.example.yaml new file mode 100644 index 0000000..beccd57 --- /dev/null +++ b/apps/searxng/secret.example.yaml @@ -0,0 +1,14 @@ +# Copy to secret.yaml (gitignored), set a real secret_key, and apply once: +# kubectl apply -f secret.yaml +# The secret already exists in the cluster; this file only documents its shape. +apiVersion: v1 +kind: Secret +metadata: + name: searxng-1723974683-config + namespace: default +type: Opaque +stringData: + settings.yml: | + server: + secret_key: CHANGE-ME + use_default_settings: true diff --git a/apps/searxng/service.yaml b/apps/searxng/service.yaml new file mode 100644 index 0000000..12d119e --- /dev/null +++ b/apps/searxng/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: searxng-1723974683 + namespace: default + labels: + app.kubernetes.io/instance: searxng-1723974683 + app.kubernetes.io/name: searxng +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: searxng-1723974683 + app.kubernetes.io/name: searxng + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP diff --git a/apps/unified-streaming/deployment.yaml b/apps/unified-streaming/deployment.yaml new file mode 100644 index 0000000..30b229e --- /dev/null +++ b/apps/unified-streaming/deployment.yaml @@ -0,0 +1,69 @@ +# DIVERGENCE from live state (intentional): the live deployment has +# USP_LICENSE_KEY as a plaintext env value. Here it comes from the +# `usp-license` secret — create it (see secret.example.yaml) BEFORE applying +# this. The Rancher-era selector label is immutable; do not change it. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unifiedstreaming + namespace: unified-streaming + labels: + workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming +spec: + replicas: 3 + selector: + matchLabels: + workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + template: + metadata: + labels: + app: unifiedstreaming + workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: controller + operator: NotIn + values: + - "true" + containers: + - name: unifiedstreaming + image: unifiedstreaming/origin:latest + imagePullPolicy: Always + env: + - name: USP_LICENSE_KEY + valueFrom: + secretKeyRef: + name: usp-license + key: USP_LICENSE_KEY + livenessProbe: + httpGet: + path: / + port: 80 + failureThreshold: 3 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: 80 + failureThreshold: 3 + periodSeconds: 10 + volumeMounts: + - name: vol-apn2a + mountPath: /var/www/unified-origin + volumes: + - name: vol-apn2a + persistentVolumeClaim: + claimName: unified-streaming-pvc diff --git a/apps/unified-streaming/ingress.yaml b/apps/unified-streaming/ingress.yaml new file mode 100644 index 0000000..8a10266 --- /dev/null +++ b/apps/unified-streaming/ingress.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: unified-streaming-ing + namespace: unified-streaming +spec: + ingressClassName: traefik + rules: + - host: unified.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: unifiedstreaming-svc + port: + number: 80 diff --git a/apps/unified-streaming/kustomization.yaml b/apps/unified-streaming/kustomization.yaml new file mode 100644 index 0000000..e77c2e6 --- /dev/null +++ b/apps/unified-streaming/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml +# (gitignored) and apply manually once per cluster. +resources: + - namespace.yaml + - storage.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/apps/unified-streaming/namespace.yaml b/apps/unified-streaming/namespace.yaml new file mode 100644 index 0000000..fa059b9 --- /dev/null +++ b/apps/unified-streaming/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: unified-streaming diff --git a/apps/unified-streaming/secret.example.yaml b/apps/unified-streaming/secret.example.yaml new file mode 100644 index 0000000..45871e8 --- /dev/null +++ b/apps/unified-streaming/secret.example.yaml @@ -0,0 +1,16 @@ +# Copy to secret.yaml (gitignored), paste the real USP license key, apply once: +# kubectl apply -f secret.yaml +# Or create directly: +# kubectl create secret generic usp-license -n unified-streaming \ +# --from-literal=USP_LICENSE_KEY='' +# The current key can be read from the live deployment: +# kubectl get deploy -n unified-streaming unifiedstreaming \ +# -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="USP_LICENSE_KEY")].value}' +apiVersion: v1 +kind: Secret +metadata: + name: usp-license + namespace: unified-streaming +type: Opaque +stringData: + USP_LICENSE_KEY: CHANGE-ME diff --git a/apps/unified-streaming/service.yaml b/apps/unified-streaming/service.yaml new file mode 100644 index 0000000..28cbcb1 --- /dev/null +++ b/apps/unified-streaming/service.yaml @@ -0,0 +1,18 @@ +# ClusterIP on purpose: this was type LoadBalancer for ~2 years, but klipper-lb +# can never bind host port 80 (svclb-traefik owns it on every node), so the +# svclb pods sat Pending forever. Traffic flows through the ingress. Changed +# to ClusterIP 2026-07-26. +apiVersion: v1 +kind: Service +metadata: + name: unifiedstreaming-svc + namespace: unified-streaming +spec: + type: ClusterIP + selector: + app: unifiedstreaming + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP diff --git a/apps/unified-streaming/storage.yaml b/apps/unified-streaming/storage.yaml new file mode 100644 index 0000000..f69136d --- /dev/null +++ b/apps/unified-streaming/storage.yaml @@ -0,0 +1,32 @@ +# Origin content (ism/ismv etc.) on GlusterFS, served by all replicas. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: unified-streaming-pv +spec: + capacity: + storage: 100Gi + accessModes: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/unified-streaming +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: unified-streaming-pvc + namespace: unified-streaming +spec: + accessModes: + - ReadWriteOnce + - ReadOnlyMany + - ReadWriteMany + resources: + requests: + storage: 100Gi + storageClassName: "" + volumeName: unified-streaming-pv diff --git a/apps/vaultwarden/deployment.yaml b/apps/vaultwarden/deployment.yaml new file mode 100644 index 0000000..2613aa4 --- /dev/null +++ b/apps/vaultwarden/deployment.yaml @@ -0,0 +1,75 @@ +# Originally installed via helm chart vaultwarden-5.1.0 (release record no longer +# in cluster); managed as a raw manifest since. Selector labels are immutable — +# do not change them. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bitwarden-vaultwarden + namespace: bitwarden + labels: + app.kubernetes.io/instance: bitwarden + app.kubernetes.io/name: vaultwarden +spec: + replicas: 1 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/instance: bitwarden + app.kubernetes.io/name: vaultwarden + strategy: + type: Recreate + template: + metadata: + labels: + app.kubernetes.io/instance: bitwarden + app.kubernetes.io/name: vaultwarden + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cpu + operator: In + values: + - arm + - key: controller + operator: NotIn + values: + - "true" + containers: + - name: bitwarden-vaultwarden + image: vaultwarden/server:1.35.4 + imagePullPolicy: IfNotPresent + env: + - name: DATA_FOLDER + value: config + ports: + - name: http + containerPort: 80 + protocol: TCP + - name: websocket + containerPort: 3012 + protocol: TCP + startupProbe: + tcpSocket: + port: 80 + failureThreshold: 30 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: 80 + failureThreshold: 3 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: 80 + failureThreshold: 3 + periodSeconds: 10 + volumeMounts: + - name: config + mountPath: /config + volumes: + - name: config + persistentVolumeClaim: + claimName: bitwarden-gvolume0 diff --git a/apps/vaultwarden/ingress.yaml b/apps/vaultwarden/ingress.yaml new file mode 100644 index 0000000..cba5cfb --- /dev/null +++ b/apps/vaultwarden/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: bitwarden + namespace: bitwarden +spec: + ingressClassName: traefik + defaultBackend: + service: + name: bitwarden-vaultwarden-svc + port: + number: 8080 + rules: + - host: pm.wittyoneoff.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: bitwarden-vaultwarden-svc + port: + number: 8080 diff --git a/apps/vaultwarden/kustomization.yaml b/apps/vaultwarden/kustomization.yaml new file mode 100644 index 0000000..ea3b943 --- /dev/null +++ b/apps/vaultwarden/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - storage.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/apps/vaultwarden/namespace.yaml b/apps/vaultwarden/namespace.yaml new file mode 100644 index 0000000..04cf575 --- /dev/null +++ b/apps/vaultwarden/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: bitwarden diff --git a/apps/vaultwarden/service.yaml b/apps/vaultwarden/service.yaml new file mode 100644 index 0000000..8420efd --- /dev/null +++ b/apps/vaultwarden/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: bitwarden-vaultwarden-svc + namespace: bitwarden +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: bitwarden + app.kubernetes.io/name: vaultwarden + ports: + - name: http + port: 8080 + targetPort: 80 + protocol: TCP + - name: websocket + port: 3012 + targetPort: websocket + protocol: TCP diff --git a/apps/vaultwarden/storage.yaml b/apps/vaultwarden/storage.yaml new file mode 100644 index 0000000..acd92eb --- /dev/null +++ b/apps/vaultwarden/storage.yaml @@ -0,0 +1,30 @@ +# GlusterFS-backed volume: /mnt/gvolume0 is the gluster mount present on every node. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: bitwarden-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteOnce + - ReadWriteMany + - ReadOnlyMany + persistentVolumeReclaimPolicy: Retain + volumeMode: Filesystem + hostPath: + path: /mnt/gvolume0/bitwarden +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: bitwarden-gvolume0 + namespace: bitwarden +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: "" + volumeName: bitwarden-pv diff --git a/kustomization.yaml b/kustomization.yaml new file mode 100644 index 0000000..14f083f --- /dev/null +++ b/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - apps/vaultwarden + - apps/searxng + - apps/home-assistant + - apps/nginx + - apps/pihole + - apps/unified-streaming