- New: smart-home/home-assistant-dashboard-conventions (Mushroom cards, view tabs, no Bubble Cards) - Updated: rke2, ceph, galera, proxmox, brainstorming, compound-learning, 1password-cli, smart-home-automation skills - New references: ceph-cluster-administration, docker-volume-forensics, ceph-crush-weight, ceph-ec-mixed-size
2565 lines
98 KiB
Markdown
2565 lines
98 KiB
Markdown
---
|
||
name: rke2-cluster-administration
|
||
description: "Class-level skill for RKE2 Kubernetes cluster administration on Proxmox VE. Covers cluster lifecycle (start/stop/reboot), cold-boot recovery, Cilium LB IP pool management, ArgoCD GitOps operations, node health diagnostics, and nested Proxmox SSH patterns for K8s VM management."
|
||
version: 1.0.0
|
||
tags: [rke2, kubernetes, k8s, cilium, argocd, gitops, proxmox, loadbalancer, lb-pool, cold-boot]
|
||
---
|
||
|
||
## Overview
|
||
|
||
This skill covers administration of an RKE2 Kubernetes cluster running on
|
||
Proxmox VE QEMU VMs. The cluster uses:
|
||
- **RKE2** v1.35.x (CNCF-conformant K8s)
|
||
- **Cilium** as CNI (eBPF-based, with L2 announcements + LB IP pools)
|
||
- **ArgoCD** for GitOps (App-of-Apps pattern)
|
||
- **External Secrets Operator** synced to 1Password
|
||
- **CloudNativePG** for PostgreSQL
|
||
- **Velero** for backups
|
||
- **Ceph CSI** for RBD storage
|
||
- **Traefik** as ingress controller (RKE2-bundled)
|
||
|
||
**Note**: MariaDB Galera runs as **native VMs** (VM300-302 + MaxScale
|
||
VM310), NOT in K8s. A K8s MariaDB Operator was previously deployed but
|
||
has been removed — Galera VMs are the authoritative database.
|
||
|
||
Load this skill for any RKE2 cluster lifecycle task, node diagnostics,
|
||
ArgoCD/GitOps operations, Cilium LB pool changes, or K8s service migration.
|
||
|
||
**User preference: Always check `skills_list` for relevant skills before
|
||
starting any task.** This skill was often the right one but wasn't loaded
|
||
until late in sessions, wasting time on trial-and-error. Load it early.
|
||
|
||
**CRITICAL workflow rule (user-enforced):** Before starting ANY task on this
|
||
infrastructure, run `skills_list` and search for relevant skills FIRST. This
|
||
skill alone contains the mgmt-runner IP (10.0.30.124), SSH key paths, kubectl
|
||
location, PVE node mappings, and dozens of pitfalls — all of which are lost
|
||
time if you probe blindly without loading the skill first.
|
||
|
||
---
|
||
|
||
## Section 1: Cluster Architecture & Access
|
||
|
||
### 1.1 Cluster Topology
|
||
|
||
| Role | VMID | Node | IP | Specs |
|
||
|------|------|------|----|-------|
|
||
| CP-01 | 118 | varies (HA) | 10.0.30.51 | 4c / 12GB / 40GB |
|
||
| CP-02 | 130 | varies (HA) | 10.0.30.52 | 4c / 12GB / 40GB |
|
||
| CP-03 | 129 | varies (HA) | 10.0.30.53 | 4c / 12GB / 40GB |
|
||
| Worker-01 | 128 | varies (HA) | 10.0.30.61 | 4c / 12GB / 80GB |
|
||
| Worker-02 | 132 | varies (HA) | 10.0.30.62 | 4c / 12GB / 80GB |
|
||
| Worker-03 | 131 | varies (HA) | 10.0.30.63 | 4c / 12GB / 80GB |
|
||
|
||
**RKE2 API VIP**: 10.0.30.50 (Keepalived on CP nodes)
|
||
|
||
**Note**: PVE HA migrates VMs between nodes. Always discover which node
|
||
hosts a given VM before operating on it (see Section 6.1).
|
||
|
||
### 1.2 Management VM (kubectl access)
|
||
|
||
VM 200 (`mgmt-runner-01`) has `kubectl`, `helm`, and `git` installed.
|
||
It also serves as the self-hosted GitHub Actions runner.
|
||
|
||
**Direct SSH** from Hermes host: `ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124`
|
||
— this is the fastest path for running kubectl/helm/git commands.
|
||
|
||
**Kubeconfig location**: `/root/.kube/config` on VM 200.
|
||
Must set `KUBECONFIG=/root/.kube/config` explicitly — kubectl does not
|
||
pick it up from `~/.kube/config` via `qm guest exec`.
|
||
|
||
```bash
|
||
# Run kubectl on mgmt-runner via Proxmox
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@PVE_NODE \
|
||
"ssh -o ConnectTimeout=5 root@NODE_HOSTING_VM200 \
|
||
'qm guest exec 200 -- sh -c \"KUBECONFIG=/root/.kube/config kubectl get nodes\"'"
|
||
```
|
||
|
||
**Pitfall**: `kubectl` without explicit `KUBECONFIG=` returns
|
||
`localhost:8080 connection refused` — the default kubeconfig path isn't
|
||
honored through `qm guest exec`.
|
||
|
||
### 1.3 Getting Kubeconfig from a Control Plane Node
|
||
|
||
If the mgmt-runner doesn't have kubeconfig yet:
|
||
|
||
```bash
|
||
# Get kubeconfig from CP-01 (VM 118)
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@PVE_NODE \
|
||
"qm guest exec 118 -- cat /etc/rancher/rke2/rke2.yaml"
|
||
|
||
# Fix the server URL from 127.0.0.1 to the CP IP
|
||
# Replace 127.0.0.1 with 10.0.30.51 (CP-01 IP)
|
||
# Base64-transfer to mgmt-runner (see docker-host-administration Section 8.4)
|
||
```
|
||
|
||
---
|
||
|
||
## Section 2: Cluster Lifecycle (Start/Stop/Reboot)
|
||
|
||
### 2.1 Starting the Cluster from Cold Boot
|
||
|
||
All 6 VMs were stopped → start them in order:
|
||
|
||
```bash
|
||
# 1. Start Control Plane nodes first
|
||
# CPs: 118, 130, 129 — may be on different PVE nodes due to HA
|
||
# Start each individually to avoid HA placement races
|
||
|
||
# 2. Wait 15-30s for CPs to begin etcd convergence
|
||
sleep 15
|
||
|
||
# 3. Start Worker nodes
|
||
# Workers: 128, 131, 132
|
||
|
||
# 4. Wait for cluster to converge (2-5 minutes)
|
||
# Workers initially show NotReady — they need to retrieve
|
||
# serving-kubelet.crt from the API server (503 until CPs are ready)
|
||
```
|
||
|
||
**Pitfall**: `qm start` returns immediately ("Requesting HA start") but
|
||
HA may place the VM on a different node than expected. Always scan all
|
||
PVE nodes to find where each VM landed:
|
||
|
||
```bash
|
||
for node in proxmox1 proxmox2 proxmox3 proxmox4 proxmox5 proxmox6 proxmox7; do
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
|
||
"ssh -o ConnectTimeout=3 root@$node 'qm list | grep rke2'" 2>/dev/null
|
||
done
|
||
```
|
||
|
||
### 2.2 Cold-Boot Recovery Timeline
|
||
|
||
After all VMs are started, expect this convergence sequence:
|
||
|
||
| Time | What happens |
|
||
|------|-------------|
|
||
| 0-30s | CP nodes boot, etcd begins forming quorum |
|
||
| 30-60s | Workers connect to CP via proxy (`wss://CP_IP:9345/v1-rke2/connect`) |
|
||
| 1-2min | Workers retrieve serving-kubelet.crt (was 503 during CP convergence) |
|
||
| 2-3min | Cilium agents start on all nodes, networking comes up |
|
||
| 3-5min | ArgoCD, External Secrets, operators begin reconciling |
|
||
| 5-10min | Most pods Running, some Terminating (old replicas from before shutdown) |
|
||
| 10-15min | Databases (Galera, PostgreSQL) reach quorum and become ready |
|
||
|
||
**Patience is key.** Don't panic when pods show Unknown/Terminating
|
||
early on — this is normal cold-boot behavior, not a failure.
|
||
|
||
### 2.3 Stopping the Cluster
|
||
|
||
```bash
|
||
# 1. Stop workers first (allows graceful pod eviction)
|
||
# 2. Then stop CPs (etcd will downsize gracefully)
|
||
# 3. Verify all stopped
|
||
```
|
||
|
||
Workers stopping first prevents etcd from losing quorum abruptly.
|
||
|
||
---
|
||
|
||
## Section 3: Cilium LoadBalancer IP Pool Management
|
||
|
||
### 3.1 The LB Pool Problem
|
||
|
||
Cilium assigns LoadBalancer IPs to K8s services (Traefik, MaxScale,
|
||
PostgreSQL) from a configurable IP pool. If this pool overlaps with
|
||
existing infrastructure IPs (Galera VIPs, MaxScale VIPs, static
|
||
services), you get **IP conflicts**.
|
||
|
||
### 3.2 Checking Current Pool
|
||
|
||
```bash
|
||
KUBECONFIG=/root/.kube/config kubectl get ciliumloadbalancerippool -o yaml
|
||
```
|
||
|
||
Key fields:
|
||
```yaml
|
||
spec:
|
||
blocks:
|
||
- start: "10.0.30.200" # Pool start
|
||
stop: "10.0.30.250" # Pool end
|
||
```
|
||
|
||
### 3.3 Changing the Pool (Free Up Conflicting IPs)
|
||
|
||
When K8s LB IPs collide with existing infrastructure:
|
||
|
||
```bash
|
||
# 1. Delete the old pool
|
||
kubectl delete ciliumloadbalancerippool default-pool
|
||
|
||
# 2. Apply new pool with non-overlapping range
|
||
cat <<'EOF' | kubectl apply -f -
|
||
apiVersion: cilium.io/v2
|
||
kind: CiliumLoadBalancerIPPool
|
||
metadata:
|
||
name: default-pool
|
||
spec:
|
||
blocks:
|
||
- start: "10.0.30.200"
|
||
stop: "10.0.30.250"
|
||
disabled: false
|
||
EOF
|
||
|
||
# 3. Verify services picked up new IPs
|
||
kubectl get svc -A -o wide | grep LoadBalancer
|
||
```
|
||
|
||
Services automatically release old IPs and get new ones from the
|
||
updated pool. This is non-disruptive — Cilium handles the transition.
|
||
|
||
### 3.4 Making the Change Persistent
|
||
|
||
The pool is defined in a manifest on all CP nodes:
|
||
`/var/lib/rancher/rke2/server/manifests/cilium-l2-lb.yaml`
|
||
|
||
RKE2 auto-applies manifests from this directory on CP restart. If you
|
||
only patch the live CRD, the old pool comes back on next CP reboot.
|
||
|
||
**Fix the manifest on ALL 3 CP nodes** (use base64 transfer — see
|
||
docker-host-administration Section 8.4):
|
||
|
||
```bash
|
||
NEW_MANIFEST=$(cat <<'EOF'
|
||
---
|
||
apiVersion: "cilium.io/v2alpha1"
|
||
kind: CiliumLoadBalancerIPPool
|
||
metadata:
|
||
name: default-pool
|
||
spec:
|
||
blocks:
|
||
- start: "10.0.30.200"
|
||
stop: "10.0.30.250"
|
||
---
|
||
apiVersion: "cilium.io/v2alpha1"
|
||
kind: CiliumL2AnnouncementPolicy
|
||
metadata:
|
||
name: default-l2-policy
|
||
spec:
|
||
interfaces:
|
||
- ^eth[0-9]+
|
||
externalIPs: true
|
||
loadBalancerIPs: true
|
||
EOF
|
||
)
|
||
|
||
B64=$(echo "$NEW_MANIFEST" | base64 -w0)
|
||
|
||
# Write to all 3 CPs (discover which PVE node hosts each first!)
|
||
for combo in "NODE1 118" "NODE2 130" "NODE3 129"; do
|
||
node=$(echo $combo | cut -d' ' -f1)
|
||
vmid=$(echo $combo | cut -d' ' -f2)
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
|
||
"ssh -o ConnectTimeout=5 root@$node \
|
||
'qm guest exec $vmid -- sh -c \"echo $B64 | base64 -d > /var/lib/rancher/rke2/server/manifests/cilium-l2-lb.yaml\"'"
|
||
done
|
||
```
|
||
|
||
### 3.5 Fixing the IaC Source
|
||
|
||
The pool is also defined in the IaC repository (Ansible playbook that
|
||
bootstraps RKE2). Update and push:
|
||
|
||
```bash
|
||
# In iac-homelab repo: epic-2-k8s/ansible/playbook.yml
|
||
# Change the LB pool start/stop values to match the new range
|
||
sed -i 's/OLD_START/NEW_START/g' epic-2-k8s/ansible/playbook.yml
|
||
sed -i 's/OLD_STOP/NEW_STOP/g' epic-2-k8s/ansible/playbook.yml
|
||
git add -A && git commit -m "fix-k8s-move-cilium-lb-pool" && git push origin main
|
||
```
|
||
|
||
**Three places to update** (all must agree):
|
||
1. Live CRD (`kubectl patch` / delete+apply)
|
||
2. CP manifest files (`/var/lib/rancher/rke2/server/manifests/cilium-l2-lb.yaml`)
|
||
3. IaC repo (`epic-2-k8s/ansible/playbook.yml`)
|
||
|
||
---
|
||
|
||
## Section 4: ArgoCD GitOps
|
||
|
||
### 4.1 ArgoCD Access
|
||
|
||
- **UI**: `https://<TRAEFIK_LB_IP>/` with Host header `argocd`
|
||
(Traefik routes by hostname)
|
||
- **Admin password**: Retrieved from kubectl:
|
||
```bash
|
||
kubectl -n argocd get secret argocd-initial-admin-secret \
|
||
-o jsonpath='{.data.password}' | base64 -d
|
||
```
|
||
- **CLI**: `argocd` binary (if installed on mgmt-runner)
|
||
|
||
### 4.2 App-of-Apps Pattern
|
||
|
||
The root ArgoCD Application points to a Git repo path containing
|
||
child Application manifests:
|
||
|
||
```yaml
|
||
# Root Application (synced from epic-5-gitops/tofu)
|
||
spec:
|
||
source:
|
||
repoURL: http://GITEA_URL/dominik/iac-homelab.git
|
||
path: clusters/main/apps
|
||
targetRevision: main
|
||
syncPolicy:
|
||
automated:
|
||
prune: true
|
||
selfHeal: true
|
||
```
|
||
|
||
Child apps in `clusters/main/apps/`:
|
||
- `operators.yaml` (sync-wave 0): CloudNativePG, Velero
|
||
- `databases.yaml` (sync-wave 1): PostgreSQL
|
||
- `backups.yaml` (sync-wave 2): Velero schedules + locations
|
||
|
||
**Removed apps**: `memory.yaml` (sync-wave 3: Qdrant, Ollama, Memory API)
|
||
was removed 2026-07-12 — the `openclaw-memory` namespace is fully deleted.
|
||
See `references/rke2-upgrade-and-recovery-2026-07.md` Phase 3.
|
||
|
||
### 4.3 Adding a New Application
|
||
|
||
To deploy a new service via GitOps:
|
||
|
||
1. Create namespace manifest: `clusters/main/<service>/namespace.yaml`
|
||
2. Create deployment/service/ingress manifests in `clusters/main/<service>/`
|
||
3. Create ArgoCD Application manifest: `clusters/main/apps/<service>.yaml`
|
||
4. Push to Gitea — ArgoCD auto-syncs within minutes
|
||
|
||
**Pitfall: K8s env var interpolation order** — When using `$(VAR_NAME)`
|
||
to reference one env var inside another (e.g. building a connection
|
||
string from a secret), the referenced var MUST be listed **before** the
|
||
referencing var. K8s processes env vars top-to-bottom; if the reference
|
||
comes first, it resolves to an empty string.
|
||
|
||
```yaml
|
||
# CORRECT — HINDSIGHT_DB_PASSWORD before DATABASE_URL
|
||
env:
|
||
- name: HINDSIGHT_DB_PASSWORD
|
||
valueFrom:
|
||
secretKeyRef: ...
|
||
- name: DATABASE_URL
|
||
value: "postgresql://user:$(HINDSIGHT_DB_PASSWORD)@host/db"
|
||
|
||
# WRONG — DATABASE_URL resolves with empty password
|
||
env:
|
||
- name: DATABASE_URL
|
||
value: "postgresql://user:$(HINDSIGHT_DB_PASSWORD)@host/db"
|
||
- name: HINDSIGHT_DB_PASSWORD
|
||
valueFrom:
|
||
secretKeyRef: ...
|
||
```
|
||
|
||
**Pitfall: ExternalSecret API version** — The cluster's
|
||
ExternalSecrets Operator serves both `v1` and `v1beta1`, but existing
|
||
working ExternalSecrets all use `v1`. Using `v1beta1` can cause
|
||
`"the server could not find the requested resource"` errors in ArgoCD
|
||
even though the CRD technically serves both versions. Always use
|
||
`apiVersion: external-secrets.io/v1`.
|
||
|
||
**Pitfall: ArgoCD caches stale manifests** — When you fix a manifest
|
||
(e.g. wrong API version) and push, ArgoCD may keep trying the old
|
||
version. Deleting the Application and recreating it forces a clean
|
||
re-read from the repo. Alternatively:
|
||
```bash
|
||
kubectl annotate application -n argocd APP_NAME \
|
||
argocd.argoproj.io/refresh=hard --overwrite
|
||
```
|
||
|
||
**Pitfall: StatefulSet PVC template immutability** — If a StatefulSet
|
||
has the wrong StorageClass in its `volumeClaimTemplates`, simply
|
||
updating the manifest and letting ArgoCD sync won't fix it — the old
|
||
PVC persists. Must delete the STS + PVC manually, then let ArgoCD
|
||
recreate with the corrected manifest:
|
||
```bash
|
||
kubectl delete sts STATEFULSET_NAME -n NS --cascade=foreground
|
||
kubectl delete pvc DATA_PVC_NAME -n NS
|
||
```
|
||
|
||
**Pitfall: `kubectl rollout restart` does NOT apply manifest changes**
|
||
— `rollout restart` only restarts pods with the *current* deployment
|
||
spec. It does NOT pull new manifest changes from the repo. After
|
||
pushing a manifest fix, you must `kubectl apply -f <manifest>` first
|
||
(or wait for ArgoCD to sync), THEN `rollout restart` if needed. If you
|
||
only `rollout restart`, the new pod starts with the old spec and hits
|
||
the same error.
|
||
|
||
**Pitfall: ArgoCD does not recreate manually deleted resources** —
|
||
When you `kubectl delete` a resource (e.g. an ExternalSecret) to force
|
||
re-sync, ArgoCD may report "successfully synced (all tasks run)" but
|
||
NOT recreate the deleted resource. This happens when the resource was
|
||
already synced once and ArgoCD considers it "managed" but doesn't
|
||
detect the drift. Fix: `kubectl apply -f <manifest>` manually, or
|
||
delete the entire ArgoCD Application and recreate it.
|
||
|
||
**Pitfall: PostgreSQL PVC `lost+found` conflict** — When mounting a PVC
|
||
directly at `/var/lib/postgresql/data`, PostgreSQL fails to initialize
|
||
because the PVC filesystem contains a `lost+found` directory (created by
|
||
mkfs). The PG initdb script refuses to use a non-empty data directory.
|
||
**Fix**: Set `PGDATA` env var to a subdirectory:
|
||
```yaml
|
||
env:
|
||
- name: PGDATA
|
||
value: /var/lib/postgresql/data/pgdata
|
||
```
|
||
The volumeMount stays at `/var/lib/postgresql/data` — PGDATA just tells
|
||
Postgres to use a clean subdirectory inside it.
|
||
|
||
```yaml
|
||
# clusters/main/apps/frigate.yaml
|
||
apiVersion: argoproj.io/v1alpha1
|
||
kind: Application
|
||
metadata:
|
||
name: frigate
|
||
namespace: argocd
|
||
annotations:
|
||
argocd.argoproj.io/sync-wave: "4"
|
||
spec:
|
||
project: default
|
||
source:
|
||
repoURL: http://GITEA_URL/dominik/iac-homelab.git
|
||
targetRevision: main
|
||
path: clusters/main/frigate
|
||
destination:
|
||
server: https://kubernetes.default.svc
|
||
namespace: frigate
|
||
syncPolicy:
|
||
automated:
|
||
selfHeal: true
|
||
prune: true
|
||
syncOptions:
|
||
- CreateNamespace=true
|
||
```
|
||
|
||
### 4.3b Migrating the Git Server Into K8s (Chicken-and-Egg)
|
||
|
||
When migrating the Git server that ArgoCD depends on (e.g. Gitea from
|
||
LXC to K8s), the circular dependency requires a careful sequence:
|
||
|
||
**Database choice: Galera over CNPG (user decision 2026-07-13)**
|
||
|
||
When migrating Gitea to K8s, use the existing **MariaDB Galera cluster**
|
||
(MaxScale VIP `10.0.30.70:3306`) instead of deploying a new CNPG
|
||
PostgreSQL cluster. Rationale:
|
||
|
||
| Criterion | Galera (chosen) | CNPG (rejected) |
|
||
|-----------|-----------------|-----------------|
|
||
| HA | ✅ 3 nodes + MaxScale | ❌ 1 instance planned (no HA) |
|
||
| New resources | ❌ None | 512Mi RAM + 10Gi SSD |
|
||
| DB engine fit | ✅ MySQL = Gitea's native DB | PG works but less common |
|
||
| Backup | ✅ Existing Galera snapshots | Needs Barman/S3 setup |
|
||
| K8s-native | ❌ External | ✅ In-cluster |
|
||
|
||
Trade-off accepted: Galera binds Gitea to existing homelab infra (not
|
||
fully K8s-native), but HA + zero new resources outweigh this.
|
||
|
||
**Safe sequence:**
|
||
1. Keep the old Git server running — ArgoCD continues pulling from it
|
||
2. Create database on Galera (`CREATE DATABASE gitea; CREATE USER gitea@'%';`)
|
||
3. Deploy new Git server to K8s (Helm chart + ExternalSecrets, DB = Galera)
|
||
4. Migrate repos + metadata via Gitea Migration API (one API call per repo
|
||
brings repos + issues + labels + milestones + PRs + releases + wiki —
|
||
no manual SQLite→SQL conversion needed)
|
||
5. Verify new Git server serves identical content
|
||
6. Cutover DNS (e.g. `git.schoen.codes` → K8s LoadBalancer IP)
|
||
7. Update all ArgoCD Application `repoURL` fields: old URL → new URL
|
||
8. Push the URL change to BOTH Git servers (they must be in sync)
|
||
9. Force ArgoCD hard refresh — verify it now pulls from the new Git server
|
||
10. After 7 days of stable operation, shut down the old Git server
|
||
|
||
**Rollback:** Keep old Git server running until step 9 is confirmed. If
|
||
anything breaks: revert DNS + revert ArgoCD `repoURL` + start old server.
|
||
|
||
**Key insight:** The old Git server is NEVER modified during migration
|
||
(read-only mirror source). This guarantees zero data loss on rollback.
|
||
|
||
**Key insight:** Gitea Migration API eliminates the SQLite→SQL schema
|
||
conversion entirely. Each `POST /api/v1/repos/migrate` call clones the
|
||
repo + imports issues/labels/milestones/PRs/releases/wiki in one shot.
|
||
This is vastly simpler than dumping SQLite, converting schemas, and
|
||
importing into PG/MySQL manually.
|
||
|
||
**Pattern reuse:** This sequence applies to any self-hosted GitOps
|
||
dependency migration (Gitea, GitLab, Forgejo). The Helm chart and DB
|
||
details change, but the chicken-and-egg sequence is universal.
|
||
|
||
#### Gitea Migration API: ALLOWED_HOST_LIST (NOT ALLOWED_HOSTS)
|
||
|
||
Gitea 1.26 blocks repo migrations from private IPs by default. The
|
||
setting to allowlist source hosts is under `[migrations]` (plural)
|
||
and the key is `ALLOWED_HOST_LIST` — NOT `ALLOWED_HOSTS`.
|
||
|
||
```yaml
|
||
# In Helm values (gitea.config):
|
||
migrations:
|
||
ALLOWED_HOST_LIST: private,external,loopback,10.0.30.105
|
||
ALLOW_LOCALNETWORKS: true
|
||
```
|
||
|
||
Built-in categories: `loopback` (localhost), `private` (LAN/intranet),
|
||
`external` (public hosts), `*` (all hosts). Can also use CIDR
|
||
(`10.0.30.0/24`) or wildcard hosts (`*.mydomain.com`).
|
||
|
||
**Pitfall:** `[migration]` (singular) is silently ignored — the
|
||
section MUST be `[migrations]` (plural). Both `ALLOWED_HOSTS` and
|
||
`ALLOW_LOCALNETWORKS` under `[migration]` have no effect.
|
||
|
||
**Pitfall:** Even `ALLOWED_HOST_LIST: *` fails if the section name is
|
||
wrong. Verify with `grep migrations /data/gitea/conf/app.ini` inside
|
||
the pod — the section header must be `[migrations]`.
|
||
|
||
**Requires pod restart:** app.ini changes are only read at Gitea
|
||
startup. After changing migration settings, delete the pod to force
|
||
restart (ArgoCD selfHeal recreates it).
|
||
|
||
#### Gitea Migration API: PR Import Failure (Retry Without PRs)
|
||
|
||
Some repos fail during migration with:
|
||
- `"error while listing pull requests (page: 1, pagesize: 49). Error: not found"`
|
||
- `"initRepository: getRepositoryByID: repository does not exist"`
|
||
|
||
This is a Gitea Migration API bug affecting repos with empty or
|
||
corrupted PR metadata. **Fix:** Delete the half-created repo, then
|
||
retry with `pull_requests: false`:
|
||
|
||
```python
|
||
# First: DELETE /api/v1/repos/{owner}/{name} (cleanup half-created)
|
||
# Then: POST /api/v1/repos/migrate with pull_requests=False
|
||
migrate_data = {
|
||
"clone_addr": f"{CT108_URL}/{owner}/{name}.git",
|
||
"repo_owner": owner, "repo_name": name,
|
||
"mirror": False, "wiki": True, "issues": True,
|
||
"labels": True, "milestones": True,
|
||
"pull_requests": False, # <-- key fix
|
||
"releases": True, "service": "gitea",
|
||
"auth_token": TOKEN,
|
||
}
|
||
```
|
||
|
||
In practice, ~17% of repos (4/23) needed this retry. All succeeded
|
||
without PRs.
|
||
|
||
#### ArgoCD App-of-Apps: Hard Refresh for New Child Apps
|
||
|
||
When adding new Application manifests to `clusters/main/apps/`, the
|
||
root App-of-Apps must be hard-refreshed to discover them:
|
||
|
||
```bash
|
||
# 1. Hard refresh root app to discover new child Application manifests
|
||
kubectl patch application root -n argocd --type merge \
|
||
-p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}'
|
||
|
||
# 2. Wait 15-30s for root to sync (creates child Application objects)
|
||
|
||
# 3. Hard refresh the new child app to force Helm chart pull
|
||
kubectl patch application gitea -n argocd --type merge \
|
||
-p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}'
|
||
```
|
||
|
||
Without the root refresh, new child apps don't appear at all. Without
|
||
the child refresh, ArgoCD may use stale cached Helm values.
|
||
|
||
**Pitfall:** `kubectl patch` with `--type merge` for the annotation
|
||
works reliably. `kubectl annotate` also works but sometimes the
|
||
annotation is consumed before the next reconciliation cycle.
|
||
|
||
**Reference:** Full migration plan at
|
||
`docs/plans/2026-07-13-gitea-k8s-migration.md` in the iac-homelab repo.
|
||
|
||
### 4.3c Gitea Helm Chart v12 Pitfalls (2026-07-14)
|
||
|
||
#### DB Password Injection: `additionalConfigFromEnvs` (NOT `env`)
|
||
|
||
The Gitea Helm Chart v12 generates `app.ini` in an init container
|
||
(`configure-gitea`) which runs `gitea migrate`. If the DB password is
|
||
not in `app.ini`, migration fails with `Access denied (using password: NO)`.
|
||
|
||
**Wrong approaches (do NOT use):**
|
||
- `gitea.env` — ignored by the chart (not a valid chart value path)
|
||
- `gitea.database.existingSecret` — not a valid chart value
|
||
- `gitea.config.database.PASSWD: __placeholder__` — literal placeholder
|
||
reaches the DB, not a secret reference
|
||
- `deployment.env` — only goes into the main container, NOT the init
|
||
containers that run `gitea migrate`
|
||
|
||
**Correct approach:** `gitea.additionalConfigFromEnvs` — this is
|
||
templated into ALL init containers (init-directories, init-app-ini,
|
||
configure-gitea) AND the main container:
|
||
|
||
```yaml
|
||
gitea:
|
||
config:
|
||
database:
|
||
DB_TYPE: mysql
|
||
HOST: 10.0.30.70:3306
|
||
NAME: gitea
|
||
USER: gitea
|
||
# No PASSWD here — injected via additionalConfigFromEnvs below
|
||
additionalConfigFromEnvs:
|
||
- name: GITEA__database__PASSWD
|
||
valueFrom:
|
||
secretKeyRef:
|
||
name: gitea-db
|
||
key: password
|
||
```
|
||
|
||
The `GITEA__database__PASSWD` env var overrides `app.ini` at runtime via
|
||
Gitea's environment-based config system (`GITEA__section__KEY`).
|
||
|
||
**Key insight:** The chart template checks `deployment.env` (goes into
|
||
all containers) and `gitea.additionalConfigFromEnvs` (goes into init +
|
||
main containers). The `gitea.env` path does NOT exist in the chart
|
||
template — it is silently ignored.
|
||
|
||
#### Valkey/Redis Auto-Deployed Despite `redis-cluster: false`
|
||
|
||
Chart v12 replaced `redis-cluster` with `valkey-cluster`. Setting
|
||
`redis-cluster.enabled: false` alone does NOT disable the cache — you
|
||
must ALSO set:
|
||
|
||
```yaml
|
||
valkey-cluster:
|
||
enabled: false
|
||
```
|
||
|
||
Otherwise 3 Valkey pods deploy automatically and consume resources.
|
||
They also fail to form a cluster (minority partition) if DNS isn't
|
||
ready, producing noisy `cluster_state:fail` warnings.
|
||
|
||
#### RWO PVC Blocks New Pod During Rollout
|
||
|
||
With `persistence.accessModes: [ReadWriteOnce]` and `replicaCount: 1`,
|
||
ArgoCD's rolling update creates a new pod before the old one terminates.
|
||
Both pods compete for the same RWO PVC — the new pod stays in
|
||
`Init:0/3` with `FailedAttachVolume: Multi-Attach error`.
|
||
|
||
**Fix:** Scale to 0 first, wait for PVC release, then scale to 1:
|
||
```bash
|
||
kubectl scale deploy -n gitea gitea --replicas=0
|
||
sleep 15
|
||
kubectl scale deploy -n gitea gitea --replicas=1
|
||
```
|
||
|
||
#### Galera Schema Creation Slowness
|
||
|
||
First-time Gitea DB migration creates ~104 tables. On Galera with
|
||
synchronous 3-node replication, each `CREATE TABLE` + index takes
|
||
longer than on a single-node DB. Total migration time: 5-8 minutes
|
||
(vs <1 min on standalone MySQL). The init container shows
|
||
`Init:2/3` during this period — do NOT assume it's hung.
|
||
|
||
Check progress via Galera:
|
||
```bash
|
||
# On a Galera node (SSH debian@10.0.30.72, sudo mariadb):
|
||
SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='gitea';
|
||
SHOW PROCESSLIST; -- look for "creating table" or "Committing alter table"
|
||
```
|
||
|
||
#### ArgoCD App Split: Config vs Helm
|
||
|
||
Split the Gitea deployment into two ArgoCD Applications:
|
||
- `gitea-config` (sync-wave 1): namespace + ExternalSecrets from Git
|
||
repo (`clusters/main/gitea/`)
|
||
- `gitea` (sync-wave 2): Helm chart from `https://dl.gitea.com/charts/`
|
||
(direct Helm source, not from Git)
|
||
|
||
This ensures secrets are synced before the Helm chart tries to use them.
|
||
|
||
### 4.3d CoreDNS Internal DNS Overrides (2026-07-14)
|
||
|
||
When migrating services to K8s that other K8s workloads need to reach
|
||
by hostname (e.g. `git.schoen.codes` for ArgoCD's repoURL), use a
|
||
CoreDNS `hosts` plugin override via `HelmChartConfig` — NOT external
|
||
DNS. This keeps resolution internal to the cluster.
|
||
|
||
**RKE2 CoreDNS chart uses `servers[].plugins[]` structure** — a flat
|
||
`corefile:` key in `valuesContent` does NOT work:
|
||
|
||
```yaml
|
||
# clusters/main/operators/coredns-config.yaml
|
||
apiVersion: helm.cattle.io/v1
|
||
kind: HelmChartConfig
|
||
metadata:
|
||
name: rke2-coredns
|
||
namespace: kube-system
|
||
spec:
|
||
valuesContent: |-
|
||
servers:
|
||
- zones:
|
||
- zone: .
|
||
port: 53
|
||
plugins:
|
||
- name: errors
|
||
- name: health
|
||
configBlock: |
|
||
lameduck 10s
|
||
- name: ready
|
||
- name: hosts
|
||
parameters: |
|
||
10.0.30.203 git.schoen.codes
|
||
fallthrough
|
||
- name: kubernetes
|
||
parameters: cluster.local in-addr.arpa ip6.arpa
|
||
configBlock: |
|
||
pods insecure
|
||
fallthrough in-addr.arpa ip6.arpa
|
||
ttl 30
|
||
- name: prometheus
|
||
parameters: 0.0.0.0:9153
|
||
- name: forward
|
||
parameters: . /etc/resolv.conf
|
||
- name: cache
|
||
parameters: 30
|
||
- name: loop
|
||
- name: reload
|
||
- name: loadbalance
|
||
```
|
||
|
||
**Pitfall: `valuesContent: corefile: |` does NOT work.** The RKE2
|
||
CoreDNS chart template renders the Corefile from `servers[].plugins[]`,
|
||
not from a raw `corefile` key. A `corefile:` value is silently ignored.
|
||
|
||
**Pitfall: HelmChartConfig applies but ConfigMap doesn't update
|
||
immediately.** The HelmChartConfig modifies the HelmChart spec, but
|
||
the Helm controller must re-run the helm install job to render the new
|
||
ConfigMap. This can take 1-2 minutes. To force immediate application,
|
||
either:
|
||
- Annotate the HelmChart: `kubectl annotate helmchart rke2-coredns -n
|
||
kube-system helm.cattle.io/retrigger="$(date +%s)" --overwrite`
|
||
- OR directly patch the ConfigMap (faster, but non-persistent — the
|
||
HelmChartConfig ensures it survives future redeploys):
|
||
```bash
|
||
kubectl patch cm rke2-coredns-rke2-coredns -n kube-system --type merge \
|
||
-p '{"data":{"Corefile":".:53 {\n errors\n health {\n lameduck 10s\n }\n ready\n hosts {\n 10.0.30.203 git.schoen.codes\n fallthrough\n }\n kubernetes cluster.local in-addr.arpa ip6.arpa {\n pods insecure\n fallthrough in-addr.arpa ip6.arpa\n ttl 30\n }\n prometheus 0.0.0.0:9153\n forward . /etc/resolv.conf\n cache 30\n loop\n reload\n loadbalance\n}\n"}}'
|
||
```
|
||
|
||
**Pitfall: CoreDNS `reload` plugin takes ~30s to pick up ConfigMap
|
||
changes.** After patching the ConfigMap, DNS queries for the new host
|
||
may still return NXDOMAIN for 30-60s. The `cache` plugin may also
|
||
cache the negative result. Wait 35s then retest.
|
||
|
||
**Verification:**
|
||
```bash
|
||
# From inside any K8s pod:
|
||
kubectl exec -n gitea deploy/gitea -- nslookup git.schoen.codes 10.43.0.10
|
||
# Expected: Name: git.schoen.codes, Address: 10.0.30.203
|
||
```
|
||
|
||
**User preference: internal DNS, not external/public.** When resolving
|
||
K8s-internal service hostnames (like `git.schoen.codes`), use CoreDNS
|
||
overrides — NOT public DNS records. This keeps traffic internal and
|
||
avoids hairpin routing through the firewall.
|
||
|
||
### 4.3e ArgoCD Self-Management Cutover (2026-07-14)
|
||
|
||
Complete sequence to switch ArgoCD from an external Git server to a
|
||
K8s-internal Gitea (chicken-and-egg resolution):
|
||
|
||
**Prerequisites:**
|
||
- New Gitea running in K8s, repos migrated, reachable via Traefik VIP
|
||
- CoreDNS override for `git.schoen.codes` → Traefik VIP (Section 4.3d)
|
||
|
||
**Step 1: Create ArgoCD repository secret**
|
||
```bash
|
||
# Generate a token on the new Gitea
|
||
kubectl exec -n gitea deploy/gitea -- gitea admin user generate-access-token \
|
||
-u dominik -t argocd --raw --config /data/gitea/conf/app.ini
|
||
|
||
# Create K8s secret (MUST be labeled for ArgoCD to recognize it!)
|
||
kubectl create secret generic argocd-repo-k8s-gitea -n argocd \
|
||
--from-literal=url="http://git.schoen.codes/dominik/iac-homelab.git" \
|
||
--from-literal=username="dominik" \
|
||
--from-literal(password)="TOKEN_VALUE" \
|
||
--from-literal=type="git" \
|
||
--dry-run=client -o yaml | kubectl apply -f -
|
||
|
||
# CRITICAL: Label the secret or ArgoCD ignores it
|
||
kubectl label secret argocd-repo-k8s-gitea -n argocd \
|
||
argocd.argoproj.io/secret-type=repository --overwrite
|
||
```
|
||
|
||
**Pitfall: Without the `argocd.argoproj.io/secret-type=repository`
|
||
label, ArgoCD does NOT recognize the secret as a repo credential.**
|
||
Symptoms: `ComparisonError: failed to generate manifest: authentication
|
||
required: Unauthorized` even though the secret exists with correct data.
|
||
|
||
**Step 2: Patch all ArgoCD Application repoURLs**
|
||
```bash
|
||
# Patch root + all child apps that reference the old URL
|
||
kubectl patch application root -n argocd --type json \
|
||
-p '[{"op":"replace","path":"/spec/source/repoURL","value":"http://git.schoen.codes/dominik/iac-homelab.git"}]'
|
||
|
||
# Patch all remaining apps referencing old URL
|
||
for app in $(kubectl get applications -n argocd -o jsonpath='{range .items[?(@.spec.source.repoURL=="http://OLD_URL")]}{.metadata.name}{"\n"}{end}'); do
|
||
kubectl patch application $app -n argocd --type json \
|
||
-p '[{"op":"replace","path":"/spec/source/repoURL","value":"http://git.schoen.codes/dominik/iac-homelab.git"}]'
|
||
done
|
||
```
|
||
|
||
**Step 3: Hard refresh and verify**
|
||
```bash
|
||
kubectl patch application root -n argocd --type merge \
|
||
-p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}'
|
||
# Wait 20-30s
|
||
kubectl get applications -n argocd
|
||
# Root should show Synced/Healthy
|
||
kubectl get application root -n argocd -o jsonpath='{.status.sync.revision}'
|
||
# Should show a commit hash from the new Gitea
|
||
```
|
||
|
||
**Result:** ArgoCD now pulls from K8s Gitea via internal DNS → Cilium
|
||
VIP → Traefik → Gitea pod. The entire GitOps loop is self-contained in
|
||
K8s. Old Git server (CT108) can be kept as fallback, then decommissioned.
|
||
|
||
### 4.3f Traefik Migration: LXC → K8s (2026-07-14)
|
||
|
||
Migrating reverse-proxy services from a standalone Traefik LXC (CT99999)
|
||
to the RKE2-bundled Traefik DaemonSet. The LXC Traefik runs on VLAN 60
|
||
(10.0.60.10), public IP NATs to it. K8s Traefik runs as DaemonSet with
|
||
hostPort 80+443 on all 6 K8s nodes.
|
||
|
||
**K8s Traefik architecture:**
|
||
- DaemonSet `rke2-traefik` in `kube-system` (not Deployment)
|
||
- `hostPort: 80` and `hostPort: 443` on every node
|
||
- IngressClass: `traefik`
|
||
- No certResolver configured by default — needs Let's Encrypt setup
|
||
|
||
**Pattern for external backends (not in K8s):**
|
||
|
||
Use `ExternalName` Service + `IngressRoute` CRD:
|
||
|
||
```yaml
|
||
apiVersion: traefik.io/v1alpha1
|
||
kind: IngressRoute
|
||
metadata:
|
||
name: grafana
|
||
namespace: proxy
|
||
spec:
|
||
entryPoints:
|
||
- websecure
|
||
routes:
|
||
- match: Host(`grafana.familie-schoen.com`)
|
||
kind: Rule
|
||
services:
|
||
- name: grafana-external
|
||
port: 3000
|
||
tls:
|
||
certResolver: letsencrypt
|
||
---
|
||
apiVersion: v1
|
||
kind: Service
|
||
metadata:
|
||
name: grafana-external
|
||
namespace: proxy
|
||
spec:
|
||
type: ExternalName
|
||
externalName: 10.0.30.141
|
||
```
|
||
|
||
**HA Failover VIP via Cilium L2 (implemented 2026-07-14):**
|
||
|
||
Instead of pointing the firewall NAT at a single K8s node (SPOF), a
|
||
`LoadBalancer` Service requests a VIP from the existing Cilium LB IPAM
|
||
pool (`10.0.30.200–250`). Cilium announces the VIP via ARP on one node;
|
||
auto-failover to another node on failure (~1-2s).
|
||
|
||
```
|
||
Firewall NAT → 10.0.30.203 (Cilium VIP)
|
||
↓ L2 Announcement (ARP)
|
||
[aktiver K8s Node — Traefik DaemonSet]
|
||
Automatisches Failover bei Node-Ausfall (~1-2s)
|
||
```
|
||
|
||
Components deployed:
|
||
- `clusters/main/proxy/traefik-lb-service.yaml` — `Service` type
|
||
`LoadBalancer` with `loadBalancerIP: 10.0.30.203`, selects Traefik pods
|
||
- `clusters/main/proxy/namespace.yaml` — `proxy` namespace for shared
|
||
IngressRoutes
|
||
- `clusters/main/apps/proxy.yaml` — ArgoCD App for the proxy namespace
|
||
|
||
**Why Cilium L2 (not MetalLB/keepalived):** Cilium L2 LB IPAM is already
|
||
running (3 services use it: .200 postgres, .201 hindsight, .202 gitea-ssh).
|
||
No additional software needed. Pool `CiliumLoadBalancerIPPool` =
|
||
`default-pool`, policy `CiliumL2AnnouncementPolicy` = `default-l2-policy`.
|
||
|
||
**Verification:** VIP `10.0.30.203` tested — Gitea + ArgoCD reachable
|
||
through it. `404` for unmatched hosts (correct — no default route).
|
||
|
||
**Firewall cutover:** Change NAT target `10.0.60.10` → `10.0.30.203`.
|
||
Rollback: revert NAT. CT99999 stays running throughout.
|
||
|
||
**Remaining migration steps:**
|
||
1. Add Let's Encrypt certResolver via HelmChartConfig for `rke2-traefik`
|
||
2. Create IngressRoute + ExternalName Service for each external backend
|
||
3. Handle catchall-TCP (make specific, not HostSNI(`*`))
|
||
4. Cutover firewall NAT: `10.0.60.10` → `10.0.30.203`
|
||
5. Verify each service, then decommission CT99999 after 1 week
|
||
|
||
**Pitfall: Catchall-TCP breaks SNI routing.** A `HostSNI(\`*\`)` TCP
|
||
passthrough IngressRoute intercepts ALL TLS traffic on :443, breaking
|
||
HTTP IngressRoutes. Either remove the catchall or make it specific
|
||
(list actual SNI hosts).
|
||
|
||
**Pitfall: acme.json migration.** Copy the existing Let's Encrypt
|
||
`acme.json` from CT99999 into a K8s Secret mounted into the Traefik
|
||
DaemonSet to avoid re-issuing all certificates (rate limit risk).
|
||
|
||
**Pitfall: Authelia on different subnet.** Backends on non-10.0.30.x
|
||
subnets (e.g. Authelia at 192.168.100.11) need routing verified from
|
||
K8s nodes before migration.
|
||
|
||
**Reference:** Full migration plan at
|
||
`docs/plans/2026-07-14-traefik-ct99999-k8s-migration.md` in iac-homelab.
|
||
|
||
**Editing Traefik dynamic config on CT99999 via SSH:**
|
||
|
||
When modifying Traefik's file-provider dynamic config on the LXC host
|
||
(CT99999, `10.0.60.10`), YAML rules contain backtick-delimited Host()
|
||
expressions. Writing these via SSH heredoc breaks the backticks (shell
|
||
interprets them as command substitution). Two reliable methods:
|
||
|
||
1. **Base64 transfer** (preferred — preserves all special chars):
|
||
```bash
|
||
# Encode locally, decode on remote:
|
||
cat << 'EOF' | base64 -w0
|
||
http:
|
||
routers:
|
||
myapp:
|
||
rule: "Host(`myapp.example.com`)"
|
||
...
|
||
EOF
|
||
# Then SSH to remote and decode:
|
||
ssh root@10.0.60.10 "echo '<BASE64>' | base64 -d > /etc/traefik/conf.d/myfile.yml"
|
||
```
|
||
|
||
2. **Backup before edit, then `systemctl restart traefik`**:
|
||
```bash
|
||
cp /etc/traefik/conf.d/explicit-http.yml{,.bak-$(date +%F)}
|
||
# Apply changes
|
||
systemctl restart traefik
|
||
# Check for YAML errors:
|
||
tail -5 /var/log/traefik/traefik.log
|
||
```
|
||
|
||
**Pitfall:** Traefik's file watcher (`watch: true` in static config) may
|
||
not immediately detect changes made via `cp` overwrite. If the log still
|
||
shows old errors with the same timestamp, `systemctl restart traefik`
|
||
forces a clean reload.
|
||
|
||
**Pitfall:** `journalctl -u traefik` on CT99999 may show no recent
|
||
entries even after restart — Traefik logs to `/var/log/traefik/`
|
||
files, not journald. Check `tail /var/log/traefik/traefik.log` instead.
|
||
|
||
### 4.3g Hermes Host Git Remote Cutover to K8s Gitea (2026-07-14)
|
||
|
||
After ArgoCD is self-managing via K8s Gitea (Section 4.3e), the Hermes
|
||
host's own git operations (clone, pull, push to `iac-homelab`) must also
|
||
switch from the old Gitea (CT108) to the K8s Gitea.
|
||
|
||
**Challenge:** CoreDNS overrides (Section 4.3d) are K8s-internal only.
|
||
The Hermes host (outside K8s) cannot resolve `git.schoen.codes` via
|
||
CoreDNS. Public DNS must NOT be used (user preference: internal DNS
|
||
only). Solution: `/etc/hosts` entry on the Hermes host.
|
||
|
||
**Steps:**
|
||
|
||
1. **Add `/etc/hosts` entry** on the Hermes host:
|
||
```bash
|
||
echo "10.0.30.203 git.schoen.codes" | sudo tee -a /etc/hosts
|
||
```
|
||
This maps `git.schoen.codes` to the Traefik Cilium VIP. Traefik
|
||
routes by Host header, so `git clone http://git.schoen.codes/...`
|
||
works — Traefik sees the correct Host and forwards to the Gitea pod.
|
||
|
||
2. **Verify reachability** before changing the remote:
|
||
```bash
|
||
curl -s -o /dev/null -w "%{http_code}" \
|
||
-H "Host: git.schoen.codes" http://10.0.30.203/
|
||
# Expected: 200 (Gitea web UI)
|
||
```
|
||
|
||
3. **Switch git remote:**
|
||
```bash
|
||
cd ~/iac-homelab
|
||
git remote set-url origin \
|
||
"http://USER:TOKEN@git.schoen.codes/dominik/iac-homelab.git"
|
||
git fetch origin
|
||
```
|
||
|
||
**Pitfall: Gitea API tokens do NOT migrate between instances.** The
|
||
token from CT108 (`10.0.30.105:3000`) is invalid on K8s Gitea. Tokens
|
||
are stored hashed in the DB and tied to the instance. Must generate a
|
||
new token on K8s Gitea.
|
||
|
||
**Generating a new token (when kubectl is available via mgmt-runner):**
|
||
```bash
|
||
# SSH to mgmt-runner (VM200), then kubectl exec into Gitea pod:
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124 \
|
||
"KUBECONFIG=/root/.kube/config kubectl exec -n gitea deploy/gitea -- \
|
||
gitea admin user generate-access-token \
|
||
-u dominik -t hermes-host --scopes all --raw \
|
||
--config /data/gitea/conf/app.ini"
|
||
```
|
||
|
||
**Pitfall: Hermes host has no `kubectl` installed.** All kubectl
|
||
operations go through SSH to mgmt-runner (VM200, `10.0.30.124`) — see
|
||
Section 1.2. The Hermes host interacts with K8s Gitea only via HTTP
|
||
through the Traefik VIP.
|
||
|
||
**Pitfall: Port 3000 is NOT exposed on the Traefik VIP.** The K8s
|
||
Gitea is reachable from outside K8s only via Traefik on ports 80/443
|
||
(with `Host: git.schoen.codes`), NOT via `10.0.30.203:3000`. The Gitea
|
||
SSH LB (`10.0.30.202:2222`) is a separate Cilium LB service for git
|
||
over SSH.
|
||
|
||
**Pitfall: JWT (internal security token) ≠ Gitea API token.** The
|
||
1Password ExternalSecrets for Gitea contain `internal_token`,
|
||
`jwt_secret`, `secret_key`, `lfs_jwt_secret` — these are JWTs (three
|
||
base64 segments separated by dots, e.g.
|
||
`eyJhbGci....eyJuYmYi....Lyr6fqc...`), NOT API access tokens (which
|
||
are hex strings like `07efa534ea1a...`). A JWT returns `401
|
||
Unauthorized` when used as `Authorization: token <JWT>` or
|
||
`Authorization: Bearer <JWT>`. If a user provides a dotted base64
|
||
token, it is a security token — generate a real API token via
|
||
`gitea admin user generate-access-token` instead.
|
||
|
||
**Pitfall: ArgoCD repo secret also needs the new token.** After
|
||
generating a new Gitea API token for the Hermes host, the ArgoCD
|
||
repository secret (`argocd-repo-k8s-gitea`) must also be patched with
|
||
the same token, otherwise ArgoCD's repo connection breaks:
|
||
```bash
|
||
kubectl patch secret argocd-repo-k8s-gitea -n argocd --type merge \
|
||
-p '{"data":{"password":"$(echo -n TOKEN | base64)"}}'
|
||
```
|
||
|
||
**1Password Storage of Gitea Credentials:**
|
||
|
||
After generating the token and identifying the admin password, store
|
||
both in 1Password vault "Hermes":
|
||
|
||
```bash
|
||
# API token (two-step — see 1password-cli skill for the credential
|
||
# field pitfall with API Credential category):
|
||
op item create --category="API Credential" \
|
||
--title="K8s Gitea Token (hermes-gitops)" --vault="Hermes" \
|
||
username="dominik" url="http://git.schoen.codes"
|
||
op item edit "<ITEM_ID>" --vault="Hermes" \
|
||
credential="TOKEN_VALUE" hostname="git.schoen.codes"
|
||
|
||
# Admin login (Login category — one step works):
|
||
op item create --category="Login" \
|
||
--title="K8s Gitea Admin (dominik)" --vault="Hermes" \
|
||
username="dominik" password="ADMIN_PASSWORD" \
|
||
--url="http://git.schoen.codes"
|
||
```
|
||
|
||
**Reference:** `references/hermes-git-remote-cutover-2026-07.md`
|
||
|
||
### 4.3i InfluxDB K8s Migration: Fresh Deploy + Replication (2026-07-14)
|
||
|
||
Migrating InfluxDB v2 from LXC CTs (CT109 hot + CT134 archive) to a single
|
||
K8s StatefulSet. Design was developed through the Compound Engineering
|
||
brainstorming process (see Workflow Correction below).
|
||
|
||
**Two approaches were evaluated; user chose Option B (Fresh Deploy):**
|
||
|
||
| Aspect | Option A: Backup/Restore | Option B: Fresh Deploy + Replication (CHOSEN) |
|
||
|--------|--------------------------|-----------------------------------------------|
|
||
| Data transfer | `influx backup` → `influx restore --full` | `influx replication` streaming + Flux backfill |
|
||
| Token handling | Preserved from backup (HA only needs URL change) | New token; HA needs URL + token change |
|
||
| GitOps cleanliness | Restore is a one-time imperative op | Fresh deploy via GitOps, replication is declarative |
|
||
| Parallel operation | No (cutover gap) | Yes (both instances live until cutover) |
|
||
| Complexity | Lower | Higher (replication setup + backfill) |
|
||
|
||
**Chosen migration phases (Option B):**
|
||
|
||
1. Deploy empty InfluxDB on K8s (StatefulSet, `DOCKER_INFLUXDB_INIT_MODE=setup`,
|
||
Cilium L2 LB VIP `.204` from existing pool)
|
||
2. Configure replication: `influx remote create` + `influx replication create`
|
||
on CT109 → streams every new write to K8s
|
||
3. Backfill existing 90-day history via Flux `to()` with remote host
|
||
4. Recreate downsampling task as LOCAL on K8s (no remote `to()` call)
|
||
5. Cutover: change HA InfluxDB integration URL + token to K8s VIP
|
||
6. Verify: data counts match, task runs succeed, HA writes flowing
|
||
7. Decommission CT109 + CT134 after 1-week stable operation
|
||
|
||
**Archive structure decision: single instance with local task (chosen over
|
||
two-instance approach).** Both buckets (`ha_hot_90d` + `ha_archive_5y_5m`)
|
||
on one K8s instance. Downsampling task runs locally (reads hot bucket, writes
|
||
archive bucket — same instance). Rationale: 4 GB total data, ~5 MB/day growth
|
||
— splitting into two instances adds backup complexity without meaningful
|
||
risk reduction. PVC sized at 30 Gi (hot 4 GB + archive growth + headroom).
|
||
|
||
**Backup strategy for K8s instance:** Single daily `influx backup` CronJob
|
||
→ Norris S3 (same pattern as CNPG/Postgres). One backup, one restore —
|
||
covers both buckets. Ceph RBD replica-3 + S3 offsite = double protection.
|
||
|
||
**Manifest structure (GitOps):**
|
||
```
|
||
clusters/main/
|
||
apps/influxdb.yaml → ArgoCD Application (App-of-Apps child)
|
||
influxdb/
|
||
namespace.yaml → ExternalSecret for admin creds (1Password)
|
||
statefulset.yaml → InfluxDB v2.7, init mode, PVC on ceph-hdd-replica
|
||
service.yaml → LoadBalancer with Cilium L2 LB IPAM annotation
|
||
init-configmap.yaml → Post-deploy init script (create archive bucket + task)
|
||
init-job.yaml → ArgoCD PostSync hook Job to run init script
|
||
```
|
||
|
||
**Pitfall: `DOCKER_INFLUXDB_INIT_MODE=setup` runs only on first boot.** If the
|
||
PVC already has data, the init mode is skipped. The PostSync init Job handles
|
||
post-deploy configuration (archive bucket creation, task setup) idempotently.
|
||
|
||
**Pitfall: 1Password CLI may not be authenticated on the Hermes host.**
|
||
`OP_SERVICE_ACCOUNT_TOKEN` can be set but `op account list` returns `[]`.
|
||
ExternalSecrets must be created via the K8s service account token (§5.3) or
|
||
the 1Password Web UI in the "Kubernetes ESO" vault.
|
||
|
||
**Pitfall: Downsampling Task must filter numeric fields only.** Home Assistant
|
||
writes string fields (`icon_str`, `state_class_str`, `attribution_str`) alongside
|
||
numeric `value` fields. `aggregateWindow(fn: mean)` crashes on strings with
|
||
`unsupported input type for mean aggregate: string`. Fix: add
|
||
`|> filter(fn: (r) => r._field == "value")` before aggregation.
|
||
|
||
**Pitfall: CT134 archive was effectively dead (368 KB, no new data for 12 days).**
|
||
The downsampling task on CT109 was failing silently — it appeared to run
|
||
successfully but the 91-90 day window was empty (hot bucket only 76 days old).
|
||
The task crash on string fields had been masked until the window had data.
|
||
|
||
**Workflow Correction: Compound Engineering brainstorming-first (2026-07-14).**
|
||
User explicitly corrected the approach of jumping to `execute_code` and writing
|
||
manifests before designing the migration concept. For infrastructure migrations,
|
||
ALWAYS follow the Compound Engineering process:
|
||
1. Load the `brainstorming` skill FIRST
|
||
2. Explore project context + grounding scan (docs/solutions/, Hindsight)
|
||
3. Ask clarifying questions ONE AT A TIME (multiple choice preferred)
|
||
4. Propose 2-3 approaches with trade-offs
|
||
5. Present design incrementally, get approval
|
||
6. Write design doc
|
||
7. Transition to `writing-plans` skill for implementation plan
|
||
Do NOT write manifests, execute code, or take implementation action until
|
||
the design is approved. Even if the manifests seem straightforward, the
|
||
structured exploration reveals design decisions (backup strategy, token
|
||
handling, archive structure) that would otherwise be discovered too late.
|
||
|
||
**Pitfall: ArgoCD ServerSideApply does NOT update ConfigMaps with embedded scripts.**
|
||
When a ConfigMap contains a shell script (e.g. `init.sh` in `data`), and the script is
|
||
modified in Git, ArgoCD with `ServerSideApply=true` may show `OutOfSync/Healthy` but
|
||
NOT apply the updated ConfigMap content. The live ConfigMap retains the old script.
|
||
Workarounds:
|
||
1. `kubectl delete cm <name> -n <ns>` + hard refresh — ArgoCD recreates from Git
|
||
2. Execute the updated script manually via `kubectl exec` (faster for one-time fixes)
|
||
3. Consider splitting scripts into separate files mounted via a projected volume
|
||
|
||
**Pitfall: `influx task create --file` requires `option task = {}` header in Flux.**
|
||
Without `option task = { name: "task-name", every: 1h }` at the top of the Flux script,
|
||
`influx task create` fails with `400 Bad Request: invalid options: no task options defined`.
|
||
The `--name` and `--every` CLI flags set metadata but do NOT inject the `option task`
|
||
block into the Flux script itself — the script must declare it explicitly.
|
||
|
||
**Pitfall: InfluxDB replication CLI flags use `-id` suffix, not bare names.**
|
||
`influx replication create` requires `--local-bucket-id` (NOT `--local-bucket`) and
|
||
`--remote-bucket-id` (NOT `--remote-bucket`). Passing the bare flag produces
|
||
`flag provided but not defined: -local-bucket`.
|
||
|
||
**Pitfall: Flux `to()` backfill produces surprisingly small disk footprint.**
|
||
A 90-day backfill of 4.25 million rows via `from() |> range() |> to(host: remote)`
|
||
produced only 51 MB on the destination InfluxDB (vs 4.1 GB on the source). This is
|
||
normal — InfluxDB v2's columnar compression is extremely effective on fresh shards
|
||
without historical shard overhead. Verify success by counting rows, not checking disk size:
|
||
```flux
|
||
from(bucket: "ha_hot_90d") |> range(start: -90d) |> count() |> sum() |> group() |> sum()
|
||
```
|
||
|
||
**Pitfall: PostSync Hook Job does NOT re-run when only the ConfigMap changes.**
|
||
ArgoCD's `PostSync` hook with `BeforeHookCreation` deletes the old Job and creates a
|
||
new one on each sync. BUT if the Application is `OutOfSync` due to the ConfigMap diff
|
||
but ArgoCD doesn't actually apply the ConfigMap (see ServerSideApply pitfall above),
|
||
the new Job still uses the OLD ConfigMap content. The Job "succeeds" with stale data.
|
||
Fix: manually delete the ConfigMap, force refresh, then manually trigger the Job or
|
||
run the init script directly via `kubectl exec`.
|
||
|
||
**Pitfall: Backup CronJob requires multi-container pattern (init + upload).**
|
||
The `influxdb:2.7` image has NO `python3`, `aws`, `rclone`, or working `apt-get`
|
||
(apt-get install hangs/crashes). The `amazon/aws-cli:2` image tag does NOT exist —
|
||
must use a specific version like `amazon/aws-cli:2.35.22`. The `amazon/aws-cli` image
|
||
has NO `tar` command. Solution: use init-container (influxdb image) for
|
||
`influx backup` + `tar czf`, then a separate container (aws-cli image) for
|
||
`aws s3 cp`:
|
||
|
||
```yaml
|
||
initContainers:
|
||
- name: backup
|
||
image: influxdb:2.7
|
||
command: [/bin/sh, -c, |
|
||
influx backup /shared/backup --host http://influxdb:8086 \
|
||
--token "${DOCKER_INFLUXDB_INIT_ADMIN_TOKEN}" --org homelab
|
||
cd /shared && tar czf backup.tar.gz backup/]
|
||
# ...
|
||
containers:
|
||
- name: upload
|
||
image: amazon/aws-cli:2.35.22
|
||
command: [/bin/sh, -c, |
|
||
aws s3 cp /shared/backup.tar.gz s3://BUCKET/$(date +%Y-%m-%d)/backup.tar.gz \
|
||
--endpoint-url https://rgw.nbg.nsc.noris.cloud --region nsc-nbg]
|
||
# ...
|
||
```
|
||
|
||
**Pitfall: AWS CLI requires UPPERCASE env vars.** Using `envFrom` with a secret
|
||
containing lowercase keys (`aws_access_key_id`, `aws_secret_access_key`) does NOT
|
||
work — AWS CLI looks for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
|
||
Must use explicit `env` with `valueFrom` mapping:
|
||
|
||
```yaml
|
||
env:
|
||
- name: AWS_ACCESS_KEY_ID
|
||
valueFrom:
|
||
secretKeyRef:
|
||
name: influxdb-s3-backup
|
||
key: aws_access_key_id
|
||
- name: AWS_SECRET_ACCESS_KEY
|
||
valueFrom:
|
||
secretKeyRef:
|
||
name: influxdb-s3-backup
|
||
key: aws_secret_access_key
|
||
```
|
||
|
||
**Pitfall: S3 bucket must be created before first backup run.** The CronJob
|
||
fails if the bucket doesn't exist. Create it via a one-off pod.
|
||
**CRITICAL: `aws s3 mb` with `--region nsc-nbg` fails with
|
||
**Pitfall: S3 bucket must be created before first backup run.** The CronJob
|
||
fails with `argument of type 'NoneType' is not a container or iterable` if the
|
||
bucket doesn't exist. Create it via a one-off pod.
|
||
|
||
**CRITICAL: `aws s3 mb` requires `--region us-east-1`, NOT `nsc-nbg`.** Using
|
||
`--region nsc-nbg` fails with `InvalidLocationConstraint: The nsc-nbg location
|
||
**Pitfall: S3 bucket must be created before first backup run.** The CronJob
|
||
fails with `argument of type 'NoneType' is not a container or iterable` if the
|
||
bucket doesn't exist. Create it via a one-off pod:
|
||
```bash
|
||
kubectl run aws-cli-create-bucket --image=amazon/aws-cli:2.35.22 --restart=Never \
|
||
--env=AWS_ACCESS_KEY_ID=$AKI --env=AWS_SECRET_ACCESS_KEY=$SAK \
|
||
--command -- sh -c "aws s3 mb s3://homelab-influxdb-backup \
|
||
--endpoint-url https://rgw.nbg.nsc.noris.cloud --region us-east-1; sleep 5"
|
||
```
|
||
|
||
**Pitfall: `aws s3 mb` with `--region nsc-nbg` fails with `InvalidLocationConstraint`.**
|
||
Norris S3 (rgw.nbg.nsc.noris.cloud) rejects the `nsc-nbg` location constraint for
|
||
bucket creation, even though `aws s3 cp` works fine with `--region nsc-nbg` for
|
||
uploads/downloads. For `s3 mb` only, always use `--region us-east-1` (the AWS
|
||
default region suppresses the LocationConstraint header). This affects all
|
||
Norris S3 bucket creations, not just InfluxDB.
|
||
|
||
**Pitfall: Home Assistant OS SSH is on port 22222, not 22.** HA OS (qemux86-64
|
||
image) exposes SSH on port 22222 for root. The HA configuration file is at
|
||
`/mnt/data/supervisor/homeassistant/configuration.yaml` (NOT `/config/`).
|
||
Restart HA via `docker restart homeassistant` from the SSH session. The HA
|
||
Supervisor access token is in `/mnt/data/supervisor/homeassistant.json` (field
|
||
`access_token`) but the Supervisor REST API returns 404 for
|
||
`/api/supervisor/core/restart` — use `docker restart` instead.
|
||
|
||
**Reference:** `references/influxdb-k8s-migration-2026-07.md`
|
||
|
||
### 4.3j Backup CronJob for Services with PVC + External DB (2026-07-14)
|
||
|
||
When backing up a K8s service that has BOTH PVC-mounted data AND an
|
||
external database (e.g. Gitea with Galera), use a multi-initContainer
|
||
CronJob with dedicated RBAC for `kubectl exec`/`kubectl cp`.
|
||
|
||
**Architecture (Gitea example):**
|
||
|
||
```
|
||
initContainer 1 (gitea-files): kubectl exec tar /data → kubectl cp → emptyDir
|
||
initContainer 2 (gitea-db): mysqldump against Galera → emptyDir
|
||
container (upload): aws s3 cp both files → Norris S3
|
||
```
|
||
|
||
**RBAC required:** The backup pod needs a ServiceAccount with a Role
|
||
granting `pods/exec` and `pods/get` in the target namespace — without
|
||
this, `kubectl exec` and `kubectl cp` fail with forbidden:
|
||
|
||
```yaml
|
||
apiVersion: rbac.authorization.k8s.io/v1
|
||
kind: Role
|
||
metadata:
|
||
name: SERVICE-backup
|
||
namespace: NAMESPACE
|
||
rules:
|
||
- apiGroups: [""]
|
||
resources: ["pods"]
|
||
verbs: ["get", "list"]
|
||
- apiGroups: [""]
|
||
resources: ["pods/exec"]
|
||
verbs: ["create", "get"]
|
||
```
|
||
|
||
Bind via RoleBinding to a ServiceAccount, then set
|
||
`serviceAccountName` on the CronJob pod spec.
|
||
|
||
**File backup via kubectl exec + kubectl cp:**
|
||
|
||
Since the PVC is RWO and mounted by the running pod, you can't mount
|
||
it directly in the backup pod. Instead:
|
||
1. `kubectl exec` into the running pod to `tar czf /tmp/data.tar.gz`
|
||
2. `kubectl cp` to extract the archive to the shared emptyDir
|
||
3. `kubectl exec` to clean up the temp file
|
||
|
||
```yaml
|
||
initContainers:
|
||
- name: gitea-files
|
||
image: bitnami/kubectl:1.31
|
||
command: [/bin/sh, -c, |
|
||
kubectl exec -n gitea gitea-0 -- tar czf /tmp/gitea-data.tar.gz -C / data
|
||
kubectl cp gitea/gitea-0:/tmp/gitea-data.tar.gz /shared/gitea-data.tar.gz
|
||
kubectl exec -n gitea gitea-0 -- rm -f /tmp/gitea-data.tar.gz]
|
||
volumeMounts:
|
||
- name: shared
|
||
mountPath: /shared
|
||
```
|
||
|
||
**DB backup via mysqldump against Galera:**
|
||
|
||
Use the `mysql:8.0` image with DB credentials from an existing K8s
|
||
secret (e.g. `gitea-db`):
|
||
|
||
```yaml
|
||
initContainers:
|
||
- name: gitea-db
|
||
image: mysql:8.0
|
||
command: [/bin/sh, -c, |
|
||
mysqldump --host=10.0.30.70 --port=3306 --user=gitea \
|
||
--password="${GITEA_DB_PASS}" --single-transaction \
|
||
--routines --triggers gitea | gzip > /shared/gitea-db.sql.gz]
|
||
env:
|
||
- name: GITEA_DB_PASS
|
||
valueFrom:
|
||
secretKeyRef:
|
||
name: gitea-db
|
||
key: password
|
||
volumeMounts:
|
||
- name: shared
|
||
mountPath: /shared
|
||
```
|
||
|
||
**S3 credentials reuse across backup CronJobs:** Multiple backup
|
||
CronJobs can share the same Norris S3 account credentials (from the
|
||
`postgres-s3-backup` 1Password item). Create a new ExternalSecret per
|
||
namespace pointing to the same 1Password item keys, and a new S3
|
||
bucket per service. Bucket naming: `homelab-<service>-backup` (e.g.
|
||
`homelab-gitea-backup`, `homelab-influxdb-backup`). Each bucket must
|
||
be created via `aws s3 mb` before the first backup run (see §4.3i
|
||
pitfall).
|
||
|
||
**Schedule staggering:** Offset backup schedules to avoid concurrent
|
||
S3 uploads. InfluxDB at `0 3 * * *` (03:00 UTC), Gitea at
|
||
`30 3 * * *` (03:30 UTC).
|
||
|
||
**Pitfall: `bitnami/kubectl:1.31` tag does NOT exist.** Use full semver
|
||
(`1.31.4`). Even then, Docker Hub rate-limiting causes 3+ min pulls.
|
||
`alpine/k8s:1.31.4` same issue. When kubectl images won't pull, run the
|
||
backup from mgmt-runner (VM200) which has kubectl + mysqldump locally —
|
||
see `references/gitea-backup-cronjob-2026-07.md`.
|
||
|
||
**Pitfall: `kubectl cp` fails for large files (>~50 MB).** WebSocket
|
||
resets: `connection reset by peer`, `close 1006 abnormal closure`. Small
|
||
files work. For large PVC tars, run from mgmt-runner directly.
|
||
|
||
**Pitfall: VM200 repo remote may be stale after Gitea migration.** Still
|
||
points to CT108. `git pull` succeeds but new commits from K8s Gitea never
|
||
arrive. Fix: `git remote set-url origin <new-url>` on VM200.
|
||
|
||
**Pitfall: ArgoCD "successfully synced" may NOT create new resources.**
|
||
With `ServerSideApply=true`, new files can go undetected. Hard-refresh
|
||
twice or `kubectl apply -f` manually. Verify with `kubectl get`.
|
||
|
||
**Pitfall: hostPath volumes are node-local.** Pod on worker-01 can't read
|
||
mgmt-runner's `/tmp`. Use `emptyDir` + `kubectl cp` (small files) or
|
||
`nodeName:` pinning (fragile).
|
||
contain kubectl.
|
||
|
||
**Pitfall: `kubectl cp` is unreliable for large files (>~50 MB).** The
|
||
websocket connection used by `kubectl cp` resets before large transfers
|
||
complete (`write: connection reset by peer`, `websocket: close 1006`).
|
||
Small files (<1 MB) work fine. For large PVC backups (e.g. 143 MB Gitea
|
||
data), avoid `kubectl cp` — generate the tar directly in an initContainer
|
||
that shares an emptyDir with the upload container, or run the backup from
|
||
the mgmt-runner which has kubectl locally.
|
||
|
||
**Pitfall: `bitnami/kubectl:1.31` image tag does NOT exist.** The bitnami/kubectl
|
||
image uses full semver tags (e.g. `1.31.4`), not minor-version tags like `1.31`.
|
||
Even with correct tags, Docker Hub rate-limiting causes extremely slow pulls
|
||
(3+ min). When kubectl images won't pull, run backup from mgmt-runner (VM200)
|
||
which has kubectl locally. See `references/gitea-backup-cronjob-2026-07.md`. Solutions:
|
||
1. Pre-pull the image on all worker nodes: `crictl pull alpine/k8s:1.31.4`
|
||
2. Use an image from a non-Docker-Hub registry
|
||
3. **Run the backup from the mgmt-runner** (VM200, 10.0.30.124) which already
|
||
has kubectl + mysqldump installed — see
|
||
`references/gitea-backup-cronjob-2026-07.md` for the manual fallback script.
|
||
This is the fastest workaround when image pulls are blocked.
|
||
|
||
**Pitfall: `kubectl cp` is unreliable for files >50 MB.** The K8s API websocket
|
||
connection resets before large file transfers complete. Small files (<1 MB)
|
||
work fine. For large PVC backups (e.g. 143 MB Gitea data), avoid `kubectl cp`
|
||
between pods — generate the data in the same pod that uploads it, or run from
|
||
the mgmt-runner where `kubectl cp` writes to local disk (more reliable than
|
||
pod-to-pod transfer).
|
||
|
||
**Pitfall: hostPath volumes are node-local.** A pod using `hostPath: /tmp`
|
||
reads from the NODE it's scheduled on, not from the machine that created the
|
||
files. If files are on the mgmt-runner but the pod runs on a worker, the files
|
||
are invisible. Use `emptyDir` + `kubectl cp`, or run the backup from the
|
||
mgmt-runner directly.
|
||
|
||
**Manifest files (Gitea backup):**
|
||
- `clusters/main/gitea/backup-rbac.yaml` — SA + Role + RoleBinding
|
||
- `clusters/main/gitea/backup-s3-secret.yaml` — ExternalSecret (Norris S3)
|
||
- `clusters/main/gitea/backup-cronjob.yaml` — CronJob (3 containers)
|
||
|
||
**Reference:** `references/gitea-backup-cronjob-2026-07.md`
|
||
|
||
When the user asks "what's the next workload to migrate to K8s?", use
|
||
this structured assessment methodology. The user migrates LXC/VM
|
||
workloads to K8s incrementally — this pattern recurs for each wave.
|
||
|
||
**Step 1: Inventory all running workloads across all PVE nodes**
|
||
|
||
```bash
|
||
for node_ip in 10.0.20.10 10.0.20.20 10.0.20.30 10.0.20.40 10.0.20.50 10.0.20.60; do
|
||
echo "=== $node_ip ==="
|
||
ssh -i ~/.ssh/id_ed25519_proxmox -o ConnectTimeout=3 root@$node_ip \
|
||
"qm list 2>/dev/null; pct list 2>/dev/null" 2>/dev/null
|
||
done
|
||
```
|
||
|
||
Focus on **running** CTs/VMs only — stopped ones are already decommissioned
|
||
candidates. Note the PVE node, CT/VM ID, hostname, and resource config
|
||
(cores, memory, disk).
|
||
|
||
**Step 2: Check K8s resource availability**
|
||
|
||
```bash
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124 \
|
||
"KUBECONFIG=/root/.kube/config kubectl top nodes 2>&1"
|
||
```
|
||
|
||
Workers have the most headroom (typically 80%+ free CPU/memory).
|
||
CP nodes are ~50-60% utilized. Ensure the target workload fits.
|
||
|
||
**Step 3: Check what's already in K8s**
|
||
|
||
```bash
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124 \
|
||
"KUBECONFIG=/root/.kube/config kubectl get deploy,sts,svc -A \
|
||
--no-headers 2>&1 | grep -v 'kube-system\|argocd\|cattle\|cilium'"
|
||
```
|
||
|
||
Avoid duplicating services already running in K8s.
|
||
|
||
**Step 4: Evaluate each candidate against criteria**
|
||
|
||
| Criterion | Question |
|
||
|-----------|----------|
|
||
| **Helm chart** | Does an official/maintained Helm chart exist? |
|
||
| **Statefulness** | Stateless or simple state (single PVC) = easier |
|
||
| **Dependencies** | Does K8s Traefik/other services depend on it? |
|
||
| **Resource footprint** | Fits within worker node capacity? |
|
||
| **Risk** | What breaks if migration fails? (Auth-only = low, data = high) |
|
||
| **Value** | How much operational complexity does migration eliminate? |
|
||
|
||
**Step 5: Rank by dependency urgency + simplicity**
|
||
|
||
Prioritize workloads that **unblock other migrations**. Example: Authelia
|
||
must migrate before Traefik can fully move to K8s (Authelia is the auth
|
||
backend for every routed service). Simple, low-risk workloads that
|
||
eliminate LXC sprawl are good filler between complex migrations.
|
||
|
||
**Migration wave pattern:**
|
||
|
||
| Wave | Workload | Rationale |
|
||
|------|----------|-----------|
|
||
| 1 | Dependency services (Auth, DNS) | Unblocks proxy layer |
|
||
| 2 | Proxy/Ingress layer | All external traffic through K8s |
|
||
| 3 | Observability (InfluxDB, Grafana) | Simple Helm charts |
|
||
| 4 | High-value complex stacks (Immich) | Multi-service but highest value |
|
||
| 5 | Low-priority containers | Cleanup |
|
||
|
||
**Present results as a table** with: CT/VM ID, name, node, status,
|
||
K8s-fit rating (⭐), and a one-line recommendation. End with a ranked
|
||
recommendation and a brief rationale — the user decides what to tackle.
|
||
|
||
**Pitfall: "Running" ≠ "In use".** Before recommending a service for
|
||
migration, verify it's actually being used. A service can be running
|
||
but completely dormant:
|
||
|
||
1. **Check reverse proxy config** — grep for the service name as a
|
||
middleware (e.g. `forwardAuth` referencing Authelia). If no router
|
||
references it as a middleware, it's not protecting anything.
|
||
2. **Check data store freshness** — `ls -la` on the DB file (SQLite) or
|
||
query row counts. A 304 KB SQLite DB unchanged since August 2025 =
|
||
abandoned.
|
||
3. **Check domain currency** — if the service is configured for an old
|
||
domain (e.g. `familie-schoen.com` when the active domain is
|
||
`schoen.codes`), it's likely orphaned.
|
||
4. **Check registered users** — if only an admin + one user exist and
|
||
neither has logged in recently, the service is decorative.
|
||
|
||
Example: Authelia (CT112) was running, had 2 users, TOTP configured,
|
||
Traefik route existed — but NO router used it as a `forwardAuth`
|
||
middleware, the SQLite DB was stale, and the domain was old. Decision:
|
||
stop and decommission instead of migrating. When a service is unused,
|
||
stopping the CT (not migrating) is the correct action.
|
||
|
||
### 4.4 Removing an Application / Operator via GitOps
|
||
|
||
When removing an operator-managed application (e.g. MariaDB Operator +
|
||
its MariaDB/MaxScale CRs) from ArgoCD:
|
||
|
||
**Pitfall**: Simply deleting the YAML files from the repo and pushing
|
||
does **NOT** cleanly prune operator-managed resources. ArgoCD marks the
|
||
app `OutOfSync` but the operator's Custom Resources (CRs) hold
|
||
finalizers that prevent deletion. The operator pods must be running to
|
||
process the finalizer — but if you delete the operator first, the CRs
|
||
are orphaned and the namespace hangs in `Terminating` forever.
|
||
|
||
**Correct removal sequence**:
|
||
|
||
1. **Remove YAML files from repo** (commit + push to Gitea)
|
||
2. **Manually delete the CRs first** (while operator is still running):
|
||
```bash
|
||
kubectl delete mariadb mariadb-galera -n mariadb --timeout=120s
|
||
kubectl delete maxscale mariadb-maxscale -n mariadb # may 404 if already gone
|
||
```
|
||
3. **Wait for CRs to be fully deleted** (operator processes finalizers)
|
||
4. **Delete the namespace**:
|
||
```bash
|
||
kubectl delete namespace mariadb --timeout=30s
|
||
kubectl delete namespace mariadb-operator --timeout=30s
|
||
```
|
||
5. **Delete the CRDs** (ArgoCD may not prune these automatically):
|
||
```bash
|
||
kubectl delete crd backups.k8s.mariadb.com connections.k8s.mariadb.com \
|
||
databases.k8s.mariadb.com grants.k8s.mariadb.com \
|
||
mariadbs.k8s.mariadb.com maxscales.k8s.mariadb.com \
|
||
restores.k8s.mariadb.com sqljobs.k8s.mariadb.com \
|
||
users.k8s.mariadb.com --timeout=30s
|
||
```
|
||
6. **Force ArgoCD refresh** if apps linger:
|
||
```bash
|
||
kubectl annotate application root -n argocd \
|
||
argocd.argoproj.io/refresh-options=hard-refresh --overwrite
|
||
```
|
||
|
||
**Key insight**: ArgoCD `prune: true` removes standard K8s resources
|
||
(deployments, services, configmaps) automatically, but **operator CRDs
|
||
and their CR instances often need manual cleanup** because finalizers
|
||
require the operator to be alive to process them.
|
||
|
||
### 4.4b Removing a Non-Operator Application via GitOps
|
||
|
||
For standard apps (Deployments, Services, StatefulSets without operator
|
||
CRs), removal is simpler — no finalizer issues:
|
||
|
||
1. **Remove YAML files from repo** (ArgoCD Application manifest +
|
||
entire `clusters/main/<service>/` directory + any GitHub workflows):
|
||
```bash
|
||
cd /root/iac-homelab
|
||
rm clusters/main/apps/<service>.yaml
|
||
rm -rf clusters/main/<service>/
|
||
rm .github/workflows/<service>-*.yml # if applicable
|
||
git add -A && git commit -m "chore: remove <service> (unused)"
|
||
git push origin main
|
||
```
|
||
|
||
2. **Delete the ArgoCD Application** (cascade=foreground cleans up
|
||
managed resources):
|
||
```bash
|
||
kubectl delete application <name> -n argocd --cascade=foreground
|
||
```
|
||
|
||
3. **Delete the namespace** (if not auto-pruned — ArgoCD's
|
||
`CreateNamespace=true` creates it but doesn't always prune it):
|
||
```bash
|
||
kubectl delete ns <namespace>
|
||
# May take 10-30s if pods need to terminate first
|
||
```
|
||
|
||
4. **Verify**: `kubectl get ns <namespace>` → NotFound, `kubectl get pods -A
|
||
| grep <namespace>` → empty.
|
||
|
||
**Pitfall**: ArgoCD Application deletion with `--cascade=foreground` may
|
||
leave the namespace in `Terminating` if pods have stuck finalizers. The
|
||
standard `kubernetes` finalizer resolves once pods are evicted. If it
|
||
hangs, check for leftover ExternalSecrets or PVCs with custom
|
||
finalizers.
|
||
|
||
### 4.5 ArgoCD Pod States During Cold Boot
|
||
|
||
After cluster cold-boot, ArgoCD pods show mixed states:
|
||
- Some `Running` (new replicas scheduled by deployment controller)
|
||
- Some `Terminating` (old replicas from before shutdown)
|
||
- Some `Unknown` (node was down when status was last reported)
|
||
|
||
These resolve naturally as the deployment controller reconciles. No
|
||
manual intervention needed unless pods are stuck for 10+ minutes.
|
||
|
||
### 4.6 Deploying Python Apps with pip in Init Containers
|
||
|
||
When deploying a Python app that isn't available as a Docker image
|
||
(e.g. `hindsight-api` from PyPI), the common pattern is an init
|
||
container that runs `pip install`, storing packages in an `emptyDir`
|
||
volume shared with the main container.
|
||
|
||
**Pitfall: `pip install --target=` does NOT install console_scripts**
|
||
— Using `pip install --target=/opt/packages` copies the Python modules
|
||
but does NOT create bin/ entry points (console_scripts). The main
|
||
container will fail with `command not found` even though the package
|
||
files are present. **Fix**: Use `python -m venv` instead:
|
||
|
||
```yaml
|
||
initContainers:
|
||
- name: install-deps
|
||
image: python:3.11-slim
|
||
command: ["/bin/sh", "-c"]
|
||
args:
|
||
- |
|
||
python -m venv /opt/venv &&
|
||
/opt/venv/bin/pip install --no-cache-dir <package> &&
|
||
echo "Done"
|
||
volumeMounts:
|
||
- name: venv
|
||
mountPath: /opt/venv
|
||
containers:
|
||
- name: app
|
||
image: python:3.11-slim
|
||
command: ["/bin/sh", "-c"]
|
||
args: ["/opt/venv/bin/<binary> --host 0.0.0.0 --port 8888"]
|
||
volumeMounts:
|
||
- name: venv
|
||
mountPath: /opt/venv
|
||
readOnly: true
|
||
volumes:
|
||
- name: venv
|
||
emptyDir:
|
||
sizeLimit: 3Gi
|
||
```
|
||
|
||
**Pitfall: Heavy dependencies pull CUDA torch (526MB)** — Packages
|
||
that depend on `torch` (e.g. `sentence-transformers`,
|
||
`hindsight-api`) will pull the full CUDA-enabled torch wheel by
|
||
default, which is 526MB+ and may OOMKill the init container. **Fix**:
|
||
Install CPU-only torch first with a dedicated index, then install the
|
||
main package with `--extra-index-url` so pip finds CPU torch:
|
||
|
||
```yaml
|
||
args:
|
||
- |
|
||
/opt/venv/bin/pip install --no-cache-dir \
|
||
torch --index-url https://download.pytorch.org/whl/cpu &&
|
||
/opt/venv/bin/pip install --no-cache-dir \
|
||
<package-with-torch-dep> \
|
||
--extra-index-url https://download.pytorch.org/whl/cpu &&
|
||
echo "Done"
|
||
```
|
||
|
||
The `--index-url` for the first install ensures ONLY CPU wheels are
|
||
considered. The `--extra-index-url` for the second install allows pip
|
||
to find the already-satisfied CPU torch instead of upgrading to the
|
||
default CUDA version.
|
||
|
||
**Pitfall: Init container pip install is slow (~10min)** — Installing
|
||
100+ packages (including torch, transformers, sentence-transformers)
|
||
takes 5-10 minutes. The pod stays in `Init:0/1` during this time. Set
|
||
generous timeouts and don't assume CrashLoopBackOff means failure —
|
||
check the init container logs first.
|
||
|
||
---
|
||
|
||
## Section 5: External Secrets & 1Password
|
||
|
||
### 5.1 ClusterSecretStore
|
||
|
||
The cluster uses a single `ClusterSecretStore` backed by 1Password:
|
||
|
||
```bash
|
||
kubectl get clustersecretstore onepassword-store
|
||
# Should show READY=True
|
||
```
|
||
|
||
### 5.2 ExternalSecret Key Format (CRITICAL)
|
||
|
||
The `remoteRef.key` format is **`item-title/field-name`** — a simple
|
||
slash-separated path, NOT `op://` URIs and NOT a separate `property`
|
||
field.
|
||
|
||
```yaml
|
||
# CORRECT
|
||
spec:
|
||
data:
|
||
- secretKey: api-key
|
||
remoteRef:
|
||
key: noris-api-key/credential # item-title/field-name
|
||
|
||
# WRONG — will fail with "secret reference has invalid format"
|
||
spec:
|
||
data:
|
||
- secretKey: api-key
|
||
remoteRef:
|
||
key: noris-api-key
|
||
property: credential
|
||
```
|
||
|
||
Field names depend on the 1Password item category:
|
||
- **API Credential**: `credential`
|
||
- **Password**: `password`
|
||
- **Login**: `username`, `password`
|
||
- **Database**: `username`, `password`, `database`, `host`, `port`
|
||
|
||
### 5.3 1Password Vault Mismatch (CRITICAL PITFALL)
|
||
|
||
The K8s `ClusterSecretStore` uses a **different 1Password service
|
||
account** than the local `op` CLI on the Hermes host. Items created
|
||
with the local `op` CLI land in vault **Hermes**
|
||
(`5ythsz37hf3xminhq33drg55tm`), but the K8s ESO reads from vault
|
||
**Kubernetes ESO** (`334ykdtj5kar3jlpcrztjvx2fu`).
|
||
|
||
**Symptom**: ExternalSecret shows `SecretSyncedError: no vault matched
|
||
the secret reference query` even though the item exists in 1Password.
|
||
|
||
**Diagnosis**:
|
||
```bash
|
||
# Check which vault the K8s ClusterSecretStore uses
|
||
kubectl get clustersecretstore onepassword-store -o jsonpath='{.spec.provider.onepasswordSDK.vault}'
|
||
|
||
# Check which vaults your local op CLI can see
|
||
op vault list
|
||
|
||
# Check which vaults the K8s service account can see
|
||
K8S_TOKEN=$(kubectl get secret onepassword-token -n external-secrets -o jsonpath='{.data.token}' | base64 -d)
|
||
OP_SERVICE_ACCOUNT_TOKEN="$K8S_TOKEN" op vault list
|
||
```
|
||
|
||
**Fix**: Create 1Password items in the vault that the K8s service
|
||
account can see (vault `334ykdtj5kar3jlpcrztjvx2fu` = "Kubernetes ESO").
|
||
|
||
The K8s service account token CAN create items (not read-only as
|
||
previously assumed):
|
||
```bash
|
||
K8S_TOKEN=$(kubectl get secret onepassword-token -n external-secrets \
|
||
-o jsonpath='{.data.token}' | base64 -d)
|
||
OP_SERVICE_ACCOUNT_TOKEN="$K8S_TOKEN" op item create \
|
||
--category="API Credential" \
|
||
--title="item-name" \
|
||
--vault="334ykdtj5kar3jlpcrztjvx2fu" \
|
||
credential=SECRET_VALUE
|
||
|
||
OP_SERVICE_ACCOUNT_TOKEN="$K8S_TOKEN" op item create \
|
||
--category="Password" \
|
||
--title="item-name" \
|
||
--vault="334ykdtj5kar3jlpcrztjvx2fu" \
|
||
password=SECRET_VALUE
|
||
```
|
||
|
||
Alternatively, create items via the 1Password Web UI in the correct
|
||
vault, or move items from the Hermes vault to Kubernetes ESO.
|
||
|
||
### 5.4 Common External Secret Failures
|
||
|
||
| Error | Cause | Fix |
|
||
|-------|-------|-----|
|
||
| `rate limit exceeded` | 1Password API throttling | Wait + retry (auto-resolves) |
|
||
| `no vault matched the secret reference query` | Item in wrong vault (see §5.3) | Create item in "Kubernetes ESO" vault |
|
||
| `secret reference has invalid format` | Wrong key format (see §5.2) | Use `item-title/field-name` format |
|
||
| `more than one field matched the secret reference` | Duplicate items in 1Password vault | Deduplicate items |
|
||
| `dial tcp: lookup my.1password.com: connect: operation not permitted` | DNS resolution from pod network | Check CoreDNS, Cilium policies |
|
||
|
||
### 5.3 Checking External Secret Status
|
||
|
||
```bash
|
||
# All ExternalSecrets across cluster
|
||
kubectl get externalsecrets -A
|
||
|
||
# Detailed status for a failing secret
|
||
kubectl describe externalsecret -n NAMESPACE SECRET_NAME
|
||
```
|
||
|
||
### 5.5 StorageClasses Available
|
||
|
||
The cluster has two StorageClasses (both Ceph RBD via CSI):
|
||
|
||
| StorageClass | Provisioner | Tier | Use Case |
|
||
|---|---|---|---|
|
||
| `ceph-flash` (default) | rbd.csi.ceph.com | SSD/NVMe | Databases, hot workloads |
|
||
| `ceph-hdd-replica` | rbd.csi.ceph.com | HDD | Bulk storage, archives |
|
||
|
||
**Pitfall**: There is NO `ceph-rbd` StorageClass. Using it in a PVC
|
||
causes the pod to stay `Pending` indefinitely with no PV created.
|
||
Always use `ceph-flash` or `ceph-hdd-replica`.
|
||
|
||
---
|
||
|
||
## Section 6: Nested Proxmox SSH Patterns
|
||
|
||
### 6.1 Discovering Which PVE Node Hosts a VM
|
||
|
||
PVE HA migrates VMs between nodes. Before operating on a VM, find
|
||
which node it's on:
|
||
|
||
```bash
|
||
for node in proxmox1 proxmox2 proxmox3 proxmox4 proxmox5 proxmox6 proxmox7; do
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
|
||
"ssh -o ConnectTimeout=3 root@$node 'qm list 2>/dev/null | grep VMID'" 2>/dev/null
|
||
done
|
||
```
|
||
|
||
### 6.2 Executing Commands on VMs via qm guest exec
|
||
|
||
```bash
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@PVE_NODE \
|
||
"qm guest exec VMID -- sh -c 'command here'"
|
||
```
|
||
|
||
Output is JSON: `{"exitcode": N, "out-data": "...", "err-data": "..."}`
|
||
|
||
**Pitfall**: Complex shell commands with quotes break through nested
|
||
SSH → qm guest exec. Use base64 encoding for file writes (see
|
||
docker-host-administration Section 8.4).
|
||
|
||
**Pitfall**: `qm guest exec` requires the QEMU guest agent to be
|
||
running. Freshly booted VMs may take 30-60s for the agent to start.
|
||
|
||
### 6.3 Parsing qm guest exec JSON Output
|
||
|
||
```bash
|
||
# Pipe through python3 for clean extraction
|
||
ssh ... "qm guest exec 200 -- sh -c '...'" 2>&1 | \
|
||
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('out-data',''))"
|
||
```
|
||
|
||
---
|
||
|
||
## Section 7: IaC Repository Structure
|
||
|
||
### 7.1 dominik/iac-homelab (Gitea)
|
||
|
||
```
|
||
iac-homelab/
|
||
├── epic-1-management/ # Mgmt VM (OpenTofu + Ansible)
|
||
├── epic-2-k8s/ # 6 VMs + RKE2 bootstrap (Ansible playbook)
|
||
├── epic-3-networking-secrets/ # Cilium + External Secrets (OpenTofu)
|
||
├── epic-4-storage/ # Ceph CSI (OpenTofu)
|
||
├── epic-5-gitops/ # ArgoCD + Root App (OpenTofu)
|
||
├── epic-7-mariadb-vm/ # MariaDB Galera VMs (standalone, not K8s)
|
||
├── clusters/main/apps/ # ArgoCD App-of-Apps manifests
|
||
├── clusters/main/operators/ # Operator Helm releases
|
||
├── clusters/main/databases/ # Database cluster manifests
|
||
├── clusters/main/backups/ # Velero config
|
||
└── .github/workflows/ # CI/CD pipelines (self-hosted runner)
|
||
```
|
||
|
||
### 7.2 Gitea Token Generation
|
||
|
||
When 1Password `op --reveal` fails (service account limitation),
|
||
generate a new Gitea API token directly on the Gitea CT:
|
||
|
||
```bash
|
||
# Gitea runs as user 'gitea', binary at /usr/local/bin/gitea
|
||
# Config at /etc/gitea/app.ini, DB at /var/lib/gitea/data/gitea.db
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@PVE_NODE \
|
||
"pct exec GITEA_CT -- su -s /bin/sh gitea -c \
|
||
'GITEA_WORK_DIR=/var/lib/gitea /usr/local/bin/gitea \
|
||
admin user generate-access-token \
|
||
-u dominik -t TOKEN_NAME --scopes all --raw \
|
||
--config /etc/gitea/app.ini'"
|
||
```
|
||
|
||
**Pitfall**: Gitea 1.25+ uses `generate-access-token` (hyphenated),
|
||
not `generate accesstoken`. Run `gitea admin user --help` first to
|
||
verify the subcommand name for your version.
|
||
|
||
**Pitfall**: Gitea refuses to run as root. Must `su -s /bin/sh gitea`
|
||
and set `GITEA_WORK_DIR` environment variable.
|
||
|
||
### 7.3 Existing Gitea Tokens
|
||
|
||
Tokens are stored hashed in the Gitea SQLite DB — the full token value
|
||
cannot be recovered. Only the last 8 characters are visible:
|
||
|
||
```sql
|
||
SELECT id, uid, name, token_last_eight FROM access_token;
|
||
```
|
||
|
||
If a token is lost, generate a new one (Section 7.2).
|
||
|
||
---
|
||
|
||
## Section 8: Health Diagnostics
|
||
|
||
### 8.1 Full Cluster Health Check
|
||
|
||
```bash
|
||
KUBECONFIG=/root/.kube/config
|
||
|
||
# Nodes
|
||
kubectl get nodes -o wide
|
||
|
||
# Non-running pods (problems)
|
||
kubectl get pods -A | grep -v Running | grep -v Completed
|
||
|
||
# ArgoCD app sync status
|
||
kubectl get applications -n argocd
|
||
|
||
# External secrets
|
||
kubectl get externalsecrets -A
|
||
|
||
# LoadBalancer services
|
||
kubectl get svc -A -o wide | grep LoadBalancer
|
||
|
||
# Cilium LB pool
|
||
kubectl get ciliumloadbalancerippool
|
||
```
|
||
|
||
### 8.2 Common Cold-Boot Issues
|
||
|
||
| Issue | Resolution |
|
||
|-------|-----------|
|
||
| Workers NotReady for 2+ min | Check `journalctl -u rke2-agent` on worker — usually waiting for serving-kubelet.crt (503 from CP) |
|
||
| ceph-csi CrashLoopBackOff | Ceph cluster may not be reachable from all nodes; check Ceph mon addresses |
|
||
| External Secrets SecretSyncedError | 1Password connectivity — check if `onepassword-store` ClusterSecretStore is Ready |
|
||
| ArgoCD pods Terminating | Old replicas cleaning up — wait for deployment controller to reconcile |
|
||
| kube-controller-manager CrashLoopBackOff | Usually transient during etcd convergence; resolves when quorum is stable |
|
||
|
||
### 8.3 Checking RKE2 Service Logs
|
||
|
||
```bash
|
||
# On CP nodes
|
||
qm guest exec CP_VMID -- journalctl -u rke2-server --no-pager -n 20
|
||
|
||
# On Worker nodes
|
||
qm guest exec WORKER_VMID -- journalctl -u rke2-agent --no-pager -n 20
|
||
```
|
||
|
||
---
|
||
|
||
## Section 9: RKE2 Version Upgrades
|
||
|
||
### 9.1 GitOps-First Principle
|
||
|
||
**CRITICAL**: RKE2 upgrades must be performed via the IaC repo
|
||
(`epic-2-k8s/ansible/playbook.yml`) and Ansible — NEVER by manually
|
||
downloading tarballs and extracting them on individual nodes. The
|
||
Ansible playbook uses the `lablabs.rke2` role which handles proper
|
||
installation, rolling restarts, and configuration management.
|
||
|
||
The version is controlled by a single variable in the playbook:
|
||
```yaml
|
||
rke2_version: v1.35.2+rke2r1 # Change this to upgrade
|
||
```
|
||
|
||
### 9.2 Prerequisites for Ansible-Based Upgrade
|
||
|
||
1. **Ansible role installed** on mgmt-runner (VM200):
|
||
```bash
|
||
cd /root/iac-homelab/epic-2-k8s/ansible
|
||
ansible-galaxy install -r requirements.yml -p /root/.ansible/roles
|
||
```
|
||
|
||
2. **Inventory generated** — `inventory.yml` is normally generated by
|
||
OpenTofu (`tofu apply` creates it via `local_file` resource). If
|
||
tofu isn't available, create it manually from known IPs:
|
||
```yaml
|
||
all:
|
||
vars:
|
||
ansible_user: debian
|
||
children:
|
||
k8s_cluster:
|
||
children:
|
||
masters:
|
||
hosts:
|
||
rke2-cp-01:
|
||
ansible_host: 10.0.30.51
|
||
rke2_type: server
|
||
rke2_server_init: true
|
||
rke2-cp-02:
|
||
ansible_host: 10.0.30.52
|
||
rke2-cp-03:
|
||
ansible_host: 10.0.30.53
|
||
workers:
|
||
hosts:
|
||
rke2-worker-01:
|
||
ansible_host: 10.0.30.61
|
||
rke2-worker-02:
|
||
ansible_host: 10.0.30.62
|
||
rke2-worker-03:
|
||
ansible_host: 10.0.30.63
|
||
```
|
||
|
||
3. **RKE2 token** — extract from an existing node's config:
|
||
```bash
|
||
# Find which PVE node hosts a CP VM, then:
|
||
qm guest exec CP_VMID -- sh -c "cat /etc/rancher/rke2/config.yaml"
|
||
# Token line: token: <VALUE>
|
||
# Export before running Ansible:
|
||
export RKE2_TOKEN="<value>"
|
||
```
|
||
|
||
4. **SSH access from mgmt-runner to K8s VMs** — Ansible connects as
|
||
`debian` user via SSH. The mgmt-runner's SSH public key must be in
|
||
`/home/debian/.ssh/authorized_keys` on all 6 K8s VMs. If missing,
|
||
`ansible` will fail with `Permission denied (publickey)`.
|
||
|
||
**Generate key on VM200** (if none exists):
|
||
```bash
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124 \
|
||
"ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N '' -C 'vm200-ansible'"
|
||
```
|
||
|
||
**Distribute to all 6 K8s VMs** via `qm guest exec` (must discover
|
||
which PVE node hosts each VM first — see Section 6.1):
|
||
```bash
|
||
PUBKEY=$(ssh root@10.0.30.124 "cat ~/.ssh/id_ed25519.pub")
|
||
|
||
# For each VM, on its hosting PVE node:
|
||
ssh root@PVE_NODE "qm guest exec VMID --timeout 15 -- sh -c \
|
||
'mkdir -p /home/debian/.ssh && echo \"$PUBKEY\" >> /home/debian/.ssh/authorized_keys \
|
||
&& chown -R debian:debian /home/debian/.ssh \
|
||
&& chmod 700 /home/debian/.ssh && chmod 600 /home/debian/.ssh/authorized_keys \
|
||
&& echo OK'"
|
||
```
|
||
|
||
**Verify connectivity** before running the playbook:
|
||
```bash
|
||
ssh root@10.0.30.124 "cd /root/iac-homelab/epic-2-k8s/ansible && ansible all -m ping"
|
||
# All 6 nodes should return SUCCESS => { "ping": "pong" }
|
||
```
|
||
|
||
### 9.3 Performing the Upgrade
|
||
|
||
```bash
|
||
# On mgmt-runner (VM200):
|
||
cd /root/iac-homelab/epic-2-k8s/ansible
|
||
|
||
# 1. Edit the version in the playbook
|
||
# Change rke2_version: v1.35.2+rke2r1 → v1.35.6+rke2r1
|
||
|
||
# 2. Commit to Git (GitOps)
|
||
git add -A
|
||
git commit -m "upgrade-rke2-v1.35.6"
|
||
git push origin main
|
||
|
||
# 3. Run the playbook
|
||
export RKE2_TOKEN="<token-from-existing-node>"
|
||
ansible-playbook playbook.yml
|
||
```
|
||
|
||
The playbook has TWO plays:
|
||
- Play 1: Installs/upgrades RKE2 on all nodes via `lablabs.rke2` role
|
||
- Play 2: Rolling restart — Workers first (`serial: 1`), then CP nodes
|
||
(`serial: 1`). Each node waits for RKE2 service active + node Ready
|
||
before proceeding to the next.
|
||
|
||
### 9.4 Post-Upgrade: Transient CP Restart Failures
|
||
|
||
After the playbook completes, the PLAY RECAP may show `failed=1` on a CP
|
||
node even though the upgrade succeeded. This happens when Ansible's
|
||
`systemctl restart rke2-server` races with RKE2's internal reload from
|
||
the binary swap — the service had already started before Ansible tried
|
||
to restart it, causing a transient "already activating" error.
|
||
|
||
**Diagnosis**: Check the actual node state, don't trust the PLAY RECAP alone:
|
||
```bash
|
||
# On the "failed" CP node via qm guest exec:
|
||
qm guest exec CP_VMID -- sh -c "systemctl is-active rke2-server && rke2 --version"
|
||
|
||
# Cluster-wide:
|
||
kubectl get nodes -o wide
|
||
# If node is Ready + target version → failure was benign, no action needed
|
||
```
|
||
|
||
**Key insight**: Always verify with `kubectl get nodes -o wide` after the
|
||
playbook. If all nodes are Ready and on the target version with 0
|
||
non-running pods, the upgrade succeeded regardless of Ansible's exit
|
||
code on individual nodes.
|
||
|
||
### 9.5 Blue-Green Alternative (User Preference)
|
||
|
||
For major version upgrades or when rolling upgrades are too risky,
|
||
the user prefers blue-green:
|
||
1. Create new VMs with new RKE2 version (via OpenTofu + Ansible)
|
||
2. Join them to the existing cluster
|
||
3. Verify new nodes are Ready
|
||
4. Drain + cordon old nodes (migrate workloads)
|
||
5. Remove old nodes from cluster
|
||
6. Delete old VMs
|
||
|
||
### 9.6 CRITICAL PITFALL: RKE2 Tarball Extraction Destroys Debian 12
|
||
|
||
**NEVER** extract `rke2.linux-amd64.tar.gz` directly to `/` on a
|
||
Debian 12 system. The tarball contains:
|
||
```
|
||
bin/ → extracted to /bin/ (OVERWRITES /bin symlink!)
|
||
lib/ → extracted to /lib/ (OVERWRITES /lib symlink!)
|
||
share/ → extracted to /share/ (creates bogus directory)
|
||
```
|
||
|
||
Debian 12 uses **usrmerge**: `/bin → usr/bin`, `/lib → usr/lib`,
|
||
`/sbin → usr/sbin` are symlinks. The tarball extraction replaces
|
||
these symlinks with real directories containing only RKE2 files,
|
||
making ALL system binaries (`ls`, `sh`, `bash`, `systemctl`, `mount`)
|
||
inaccessible. The VM becomes unmanageable — no new processes can
|
||
exec, `qm guest exec` returns exit code 29.
|
||
|
||
**Correct extraction target**: `/usr/local/` (the tarball's `bin/`
|
||
contents go to `/usr/local/bin/`, etc.)
|
||
|
||
**If already broken**, see Section 10 for recovery procedure.
|
||
|
||
---
|
||
|
||
## Section 10: Recovering a Broken VM (Symlink Destruction)
|
||
|
||
### 10.1 Symptoms
|
||
|
||
- `qm guest exec VMID -- /bin/echo hello` → exit code 29
|
||
- `qm guest exec VMID -- sh -c "..."` → exit code 29
|
||
- `qm guest exec VMID -- /usr/bin/ls` → exit code 29
|
||
- K8s node still shows `Ready` (kernel + RKE2 processes still running)
|
||
- But no NEW processes can start (broken `/bin` and `/lib`)
|
||
|
||
### 10.2 Recovery: Ceph RBD Offline Mount
|
||
|
||
When the VM's disk is on Ceph RBD (`vm_disks` storage pool):
|
||
|
||
```bash
|
||
# 1. Stop the VM (preserve etcd quorum if CP node — need 2/3 alive)
|
||
qm stop VMID
|
||
|
||
# 2. On the PVE node hosting the VM, map the RBD image
|
||
rbd map vm_disks/vm-VMID-disk-0
|
||
# Returns /dev/rbdN (note the number!)
|
||
|
||
# 3. Wait for udev, then check partitions
|
||
sleep 2
|
||
lsblk | grep rbd
|
||
# Typically: rbdNp1 = root fs, rbdNp14 = BIOS boot, rbdNp15 = EFI
|
||
|
||
# 4. Mount the root partition
|
||
mkdir -p /mnt/vm-rescue
|
||
mount /dev/rbdNp1 /mnt/vm-rescue
|
||
|
||
# 5. Verify the damage
|
||
ls -la /mnt/vm-rescue/bin # Should be symlink but is real dir
|
||
ls -la /mnt/vm-rescue/lib # Should be symlink but is real dir
|
||
|
||
# 6. Fix symlinks
|
||
# Save any RKE2 binaries first:
|
||
cp /mnt/vm-rescue/bin/rke2 /mnt/vm-rescue/usr/local/bin/rke2
|
||
cp /mnt/vm-rescue/bin/rke2-*.sh /mnt/vm-rescue/usr/local/bin/
|
||
# Copy systemd units if present:
|
||
mkdir -p /mnt/vm-rescue/usr/lib/systemd/system
|
||
cp -a /mnt/vm-rescue/lib/systemd/system/rke2-*.service /mnt/vm-rescue/usr/lib/systemd/system/
|
||
cp -a /mnt/vm-rescue/lib/systemd/system/rke2-*.env /mnt/vm-rescue/usr/lib/systemd/system/
|
||
|
||
# Remove fake directories, restore symlinks:
|
||
rm -rf /mnt/vm-rescue/bin
|
||
ln -s usr/bin /mnt/vm-rescue/bin
|
||
rm -rf /mnt/vm-rescue/lib
|
||
ln -s usr/lib /mnt/vm-rescue/lib
|
||
rm -rf /mnt/vm-rescue/share # Bogus dir from tarball
|
||
|
||
# 7. Verify fix
|
||
ls -la /mnt/vm-rescue/bin # Should show: bin -> usr/bin
|
||
ls -la /mnt/vm-rescue/lib # Should show: lib -> usr/lib
|
||
ls /mnt/vm-rescue/bin/bash # Should exist
|
||
ls /mnt/vm-rescue/bin/systemctl # Should exist
|
||
|
||
# 8. Unmount, unmap, start VM
|
||
umount /mnt/vm-rescue
|
||
rbd unmap /dev/rbdN
|
||
qm start VMID
|
||
|
||
# 9. Wait 30-60s for boot, verify guest agent
|
||
qm guest exec VMID -- sh -c "echo BOOT_OK && systemctl is-active rke2-server"
|
||
```
|
||
|
||
### 10.3 RBD Mapping Pitfalls
|
||
|
||
- **RBD device disappears**: If `rbd map` succeeds but `/dev/rbdN`
|
||
vanishes before mount, re-run `rbd map` (previous mapping was stale).
|
||
Always `sleep 2` after mapping for udev to settle.
|
||
- **Partition devices not created**: `lsblk` shows partitions but
|
||
`/dev/rbdNp1` doesn't exist as a block device. Run `partprobe
|
||
/dev/rbdN` or just re-map (unmap + map again).
|
||
- **kpartx not available**: PVE nodes may not have `kpartx`. The
|
||
kernel usually creates partition devices automatically after
|
||
`rbd map` + `sleep 2`. If not, `partprobe /dev/rbdN` helps.
|
||
- **Don't mount while VM is running**: RBD is exclusive — stop the VM
|
||
first. Mounting a live RBD image will corrupt the filesystem.
|
||
|
||
### 10.4 Post-Recovery: Restore Correct RKE2 Version
|
||
|
||
If the tarball extraction was for a NEW version (e.g. v1.35.6) but
|
||
the cached runtime images on the node are for the OLD version (e.g.
|
||
v1.35.2), RKE2 will fail to start with:
|
||
```
|
||
failed to pull images: image "rancher/rke2-runtime:v1.35.6-rke2r1": not found
|
||
```
|
||
|
||
**Fix**: Download the MATCHING tarball version and extract correctly:
|
||
```bash
|
||
# On the broken VM (after symlink repair + reboot):
|
||
cd /tmp
|
||
wget https://github.com/rancher/rke2/releases/download/v1.35.2%2Brke2r1/rke2.linux-amd64.tar.gz
|
||
tar xf rke2.linux-amd64.tar.gz -C /usr/local/ # CORRECT target!
|
||
systemctl restart rke2-server
|
||
```
|
||
|
||
Then perform the version upgrade properly via Ansible (Section 9).
|
||
|
||
---
|
||
|
||
## Section 11: OS Updates on K8s VMs
|
||
|
||
### 11.1 Updating Via qm guest exec
|
||
|
||
K8s VMs don't have SSH keys from the mgmt-runner, so Ansible can't
|
||
reach them directly. Use `qm guest exec` through Proxmox:
|
||
|
||
```bash
|
||
# Discover which PVE node hosts each VM first (Section 6.1)
|
||
# Then run apt upgrade:
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@PVE_JUMPHOST \
|
||
"ssh root@PVE_NODE 'qm guest exec VMID --timeout 600 -- sh -c \"
|
||
apt-get update && apt-get upgrade -y && apt-get autoremove -y
|
||
\"'"
|
||
```
|
||
|
||
### 11.2 Interrupted dpkg Recovery
|
||
|
||
If apt upgrade is interrupted (timeout, connection drop), dpkg is left
|
||
in a half-configured state. This blocks ALL subsequent apt operations
|
||
on that node — including the Ansible playbook's pre_tasks (which install
|
||
`ceph-common` and `rbd-nbd`). The Ansible run will fail with:
|
||
```
|
||
E: dpkg was interrupted, you must manually run 'sudo dpkg --configure -a'
|
||
```
|
||
|
||
**Simple fix** (if only dpkg lock is held):
|
||
```bash
|
||
qm guest exec VMID --timeout 300 -- sh -c "dpkg --configure -a && apt-get update && apt-get upgrade -y"
|
||
```
|
||
|
||
**Full fix** (if dpkg AND debconf locks are held by stale processes):
|
||
```bash
|
||
# Via qm guest exec on the PVE node hosting the VM:
|
||
qm guest exec VMID --timeout 120 -- bash -c "
|
||
fuser -k /var/lib/dpkg/lock-frontend 2>/dev/null;
|
||
fuser -k /var/lib/dpkg/lock 2>/dev/null;
|
||
fuser -k /var/cache/debconf/config.dat 2>/dev/null;
|
||
sleep 2;
|
||
rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock;
|
||
DEBIAN_FRONTEND=noninteractive dpkg --configure -a &&
|
||
apt-get install -y ceph-common rbd-nbd
|
||
"
|
||
```
|
||
|
||
**Pitfall: debconf lock** — Even after clearing dpkg locks, `debconf`
|
||
has its own lock at `/var/cache/debconf/config.dat`. If a stale process
|
||
holds it, `dpkg --configure -a` fails with:
|
||
```
|
||
debconf: DbDriver "config": /var/cache/debconf/config.dat is locked by another process
|
||
```
|
||
Must `fuser -k /var/cache/debconf/config.dat` before retrying.
|
||
|
||
**Pitfall: Interactive prompts hang under qm guest exec** — Without
|
||
`DEBIAN_FRONTEND=noninteractive`, dpkg may prompt for config file
|
||
conflicts (e.g. `sshd_config` was locally modified) and hang indefinitely
|
||
under `qm guest exec` (no TTY). Always set
|
||
`DEBIAN_FRONTEND=noninteractive` to auto-keep-existing config.
|
||
|
||
**Cross-reference**: This must be fixed BEFORE running the Ansible
|
||
upgrade playbook (Section 9.3) — the `lablabs.rke2` role's pre_tasks
|
||
install `ceph-common` and `rbd-nbd` via apt, which will fail if dpkg
|
||
is in an interrupted state. Fix all affected nodes first, then re-run
|
||
`ansible-playbook playbook.yml`.
|
||
|
||
### 11.3 Direct SSH to mgmt-runner
|
||
|
||
VM200 (mgmt-runner) at 10.0.30.124 accepts direct SSH from the Hermes
|
||
host:
|
||
```bash
|
||
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124
|
||
```
|
||
This bypasses the Proxmox jumphost chain and is faster for running
|
||
kubectl/helm/git commands.
|
||
|
||
---
|
||
|
||
## Section 12: Actual VM→PVE-Node Mapping (July 2026)
|
||
|
||
The tofu state records where VMs were originally placed, but PVE HA
|
||
migrates VMs. The ACTUAL mapping (as of 2026-07-12) is:
|
||
|
||
| VM | Name | PVE-Node | IP |
|
||
|----|------|----------|-----|
|
||
| 118 | rke2-cp-01 | proxmox5 | 10.0.30.51 |
|
||
| 130 | rke2-cp-02 | proxmox3 | 10.0.30.52 |
|
||
| 129 | rke2-cp-03 | proxmox2 | 10.0.30.53 |
|
||
| 128 | rke2-worker-01 | proxmox | 10.0.30.61 |
|
||
| 132 | rke2-worker-02 | proxmox2 | 10.0.30.62 |
|
||
| 131 | rke2-worker-03 | proxmox3 | 10.0.30.63 |
|
||
| 200 | mgmt-runner | proxmox3 | 10.0.30.124 |
|
||
|
||
**Always re-discover** before operating — HA may have moved VMs since
|
||
this table was written.
|
||
|
||
---
|
||
|
||
## Section 13: Helm Chart Upgrades
|
||
|
||
### 13.1 Chart Inventory & Version Matrix
|
||
|
||
Before upgrading, inventory all Helm releases and compare against
|
||
latest available versions:
|
||
|
||
```bash
|
||
# On VM200 (mgmt-runner):
|
||
export KUBECONFIG=/root/.kube/config
|
||
|
||
# 1. List all Helm releases across namespaces
|
||
helm list -A
|
||
|
||
# 2. Update repo cache
|
||
helm repo update
|
||
|
||
# 3. Check latest available versions
|
||
helm search repo argo/argo-cd | head -3
|
||
helm search repo external-secrets/external-secrets | head -3
|
||
# etc.
|
||
|
||
# 4. RKE2-bundled charts (already updated with RKE2 upgrade):
|
||
helm list -n kube-system | grep rke2-
|
||
# These are: cilium, traefik, coredns, metrics-server, snapshot-controller
|
||
```
|
||
|
||
Build a matrix: Component | Current Version | Latest Available |
|
||
Update Type (Major/Minor/Patch) | Management Method (Helm-direct vs ArgoCD GitOps).
|
||
|
||
**RKE2-bundled charts** (Cilium, Traefik, CoreDNS, Metrics Server,
|
||
Snapshot Controller) are automatically updated when RKE2 is upgraded —
|
||
no separate Helm upgrade needed.
|
||
|
||
### 13.2 ArgoCD GitOps-Managed Charts (CNPG, Velero)
|
||
|
||
Charts deployed via ArgoCD Applications are upgraded by changing the
|
||
`targetRevision` in the Application manifest in the IaC repo:
|
||
|
||
```bash
|
||
cd /root/iac-homelab
|
||
# Find the Application manifest
|
||
grep -r "targetRevision" clusters/main/
|
||
|
||
# Update the chart version (e.g. CNPG 0.22.1 → 0.29.0)
|
||
sed -i 's/targetRevision: 0.22.1/targetRevision: 0.29.0/' \
|
||
clusters/main/operators/cloudnativepg.yaml
|
||
git add -A && git commit -m "feat: upgrade CNPG chart" && git push
|
||
```
|
||
|
||
**Pitfall: ArgoCD doesn't auto-refresh after chart version bump** —
|
||
Even with `selfHeal: true`, ArgoCD may keep the old chart revision
|
||
cached. Force a hard refresh:
|
||
```bash
|
||
kubectl annotate application <app-name> -n argocd \
|
||
argocd.argoproj.io/refresh=hard --overwrite
|
||
```
|
||
|
||
Wait for the new revision to appear in `kubectl get applications -n argocd`,
|
||
then monitor the rollout:
|
||
```bash
|
||
kubectl rollout status deployment/<deployment-name> -n <namespace> --timeout=300s
|
||
```
|
||
|
||
### 13.3 Helm-Direct Charts (ArgoCD, External Secrets, Ceph CSI)
|
||
|
||
Charts installed directly via `helm install` are upgraded with
|
||
`helm upgrade`:
|
||
|
||
```bash
|
||
# Save current values before upgrading
|
||
helm get values <release-name> -n <namespace> > /tmp/<release>-values.yaml
|
||
|
||
# Dry-run first
|
||
helm upgrade <release-name> <repo>/<chart> \
|
||
--version <new-version> \
|
||
-n <namespace> \
|
||
--reuse-values \
|
||
--dry-run
|
||
|
||
# Actual upgrade
|
||
helm upgrade <release-name> <repo>/<chart> \
|
||
--version <new-version> \
|
||
-n <namespace> \
|
||
--reuse-values \
|
||
--timeout 300s
|
||
```
|
||
|
||
**Pitfall: `--reuse-values` may not suffice for major chart versions** —
|
||
Major chart versions can introduce required fields that don't exist in
|
||
old values. If `helm upgrade` fails with a template error (e.g. `nil
|
||
pointer evaluating interface {}`), create a complete values file that
|
||
includes the new required fields:
|
||
```bash
|
||
# Instead of --reuse-values, provide explicit values:
|
||
helm upgrade <release-name> <repo>/<chart> \
|
||
--version <new-version> \
|
||
-n <namespace> \
|
||
-f /tmp/<release>-v3-values.yaml \
|
||
--timeout 300s
|
||
```
|
||
|
||
### 13.4 ArgoCD v2→v3 Major Upgrade (Breaking Change)
|
||
|
||
Upgrading the ArgoCD Helm chart from v7.x (app v2.x) to v10.x (app
|
||
v3.x) requires adding `redis.networkPolicy.create: false` to the
|
||
values file. Without this, the chart fails with:
|
||
```
|
||
template: argo-cd/templates/redis/networkpolicy.yaml:2:22:
|
||
nil pointer evaluating interface {}.create
|
||
```
|
||
|
||
**Safe upgrade path**: First upgrade to the latest v2.x patch
|
||
(chart 7.9.1 = app v2.14.11), then jump to v3.x:
|
||
```bash
|
||
# Step 1: Safe patch to latest v2.x
|
||
helm upgrade argocd argo/argo-cd --version 7.9.1 -n argocd --reuse-values
|
||
|
||
# Step 2: Create v3-compatible values file with redis.networkPolicy
|
||
cat > /tmp/argocd-v3-values.yaml << 'EOF'
|
||
# ... copy all existing values, then add:
|
||
redis:
|
||
networkPolicy:
|
||
create: false
|
||
EOF
|
||
|
||
# Step 3: Major upgrade to v3
|
||
helm upgrade argocd argo/argo-cd --version 10.1.3 -n argocd \
|
||
-f /tmp/argocd-v3-values.yaml --timeout 300s
|
||
```
|
||
|
||
**Save values files to the IaC repo** (`epic-2-k8s/helm/<chart>/values.yaml`)
|
||
for reproducible installs.
|
||
|
||
### 13.5 CNPG Replica Rebuild from WAL Gap
|
||
|
||
When a CNPG PostgreSQL replica falls behind and the primary has
|
||
already recycled the needed WAL segments, the replica is permanently
|
||
broken:
|
||
```
|
||
requested WAL segment 000000020000000000000008 has already been removed
|
||
```
|
||
|
||
**Fix**: Delete the broken replica's PVCs and pod — CNPG automatically
|
||
rebuilds from a fresh base backup of the primary:
|
||
```bash
|
||
# 1. Identify the broken replica pod
|
||
kubectl get pods -n postgres | grep -v Running
|
||
# e.g. postgres-main-2 is in a bad state
|
||
|
||
# 2. Delete the PVCs (data + wal)
|
||
kubectl delete pvc postgres-main-2 postgres-main-2-wal -n postgres
|
||
|
||
# 3. Delete the pod so CNPG recreates it
|
||
kubectl delete pod postgres-main-2 -n postgres
|
||
|
||
# 4. CNPG creates a new replica (e.g. postgres-main-4) automatically
|
||
# Wait for it to join:
|
||
kubectl get pods -n postgres -w
|
||
# Watch for 3/3 Running
|
||
```
|
||
|
||
**Do this BEFORE upgrading the CNPG operator** — upgrading with a
|
||
broken replica is risky. The operator upgrade itself may trigger a
|
||
primary restart, and having only 1 healthy replica reduces safety margin.
|
||
|
||
### 13.6 Ceph CSI RBD Upgrade — Transient API Server Disruption
|
||
|
||
**Pitfall**: Upgrading the Ceph CSI RBD chart triggers a DaemonSet
|
||
rolling update of `ceph-csi-rbd-nodeplugin` pods across ALL nodes,
|
||
including control plane nodes. The nodeplugin pods mount/unmount CSI
|
||
driver sockets, which can briefly disrupt the kubelet on CP nodes and
|
||
cause all 3 API servers to become unreachable for 2-5 minutes.
|
||
|
||
**Symptoms during the disruption**:
|
||
```
|
||
The connection to the server 10.0.30.51:6443 was refused
|
||
```
|
||
|
||
**Mitigation**:
|
||
- Schedule Ceph CSI upgrades during maintenance windows
|
||
- After `helm upgrade`, monitor with `curl -sk https://CP_IP:6443/healthz`
|
||
until it responds (even 401 Unauthorized means the server is up)
|
||
- Once API server recovers, verify with `kubectl get pods -n kube-system | grep ceph-csi-rbd`
|
||
- New nodeplugin pods may stay in `ContainerCreating` for several
|
||
minutes while pulling the new CSI image — this is normal
|
||
- All PVCs remain bound throughout; no data loss occurs
|
||
|
||
### 13.7 ExternalSecret Perpetual OutOfSync in ArgoCD
|
||
|
||
**Pitfall**: The External Secrets Operator adds default fields to
|
||
ExternalSecret specs that aren't present in the Git YAML:
|
||
- `remoteRef.conversionStrategy: "Default"`
|
||
- `remoteRef.decodingStrategy: "None"`
|
||
- `remoteRef.metadataPolicy: "None"`
|
||
- `target.deletionPolicy: "Retain"`
|
||
- `target.template.engineVersion: "v2"`
|
||
- `target.template.mergePolicy: "Replace"`
|
||
|
||
ArgoCD sees these as spec drift and marks the ExternalSecret
|
||
perpetually `OutOfSync`. The `ignoreDifferences` rule for
|
||
`/status` doesn't cover `/spec` differences.
|
||
|
||
**Fix**: Either add the ESO default fields to the Git YAML (so spec
|
||
matches exactly), or add `ignoreDifferences` for the specific JSON
|
||
pointers. Adding fields to Git YAML is cleaner:
|
||
```yaml
|
||
spec:
|
||
data:
|
||
- secretKey: access_key_id
|
||
remoteRef:
|
||
key: velero-s3-backup/S3 Credentials/access_key_id
|
||
conversionStrategy: Default
|
||
decodingStrategy: None
|
||
metadataPolicy: None
|
||
target:
|
||
creationPolicy: Owner
|
||
deletionPolicy: Retain
|
||
template:
|
||
engineVersion: v2
|
||
mergePolicy: Replace
|
||
```
|
||
|
||
### 13.8 Accidental .kube/cache Commit
|
||
|
||
**Pitfall**: Running `kubectl` commands from the IaC repo root creates
|
||
`.kube/cache/` with discovery API responses. If `git add -A` is used,
|
||
these cache files get committed. Always add `.kube/` to `.gitignore`:
|
||
```bash
|
||
echo ".kube/" >> .gitignore
|
||
```
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
See `references/` directory for session-specific details.
|
||
|
||
- `references/cold-boot-recovery-2026-07.md` — Session detail: full cluster cold-boot, Cilium LB pool migration from .70-.89 to .200-.250, ArgoCD convergence timeline
|
||
- `references/rke2-cluster-topology.md` — Complete topology, IP map, operator inventory, IaC repo structure
|
||
- `references/hindsight-k8s-deployment-2026-07.md` — Deploying hindsight-api in K8s: ExternalSecret format, StorageClass, env var ordering, vault mismatch, ArgoCD cache pitfalls
|
||
- `references/rke2-upgrade-and-recovery-2026-07.md` — Session detail: RKE2 tarball destruction, Ceph RBD offline recovery, Ansible-based upgrade prerequisites, OS update procedures, openclaw-memory removal via GitOps, Ansible SSH key prerequisite
|
||
- `references/helm-chart-upgrades-2026-07.md` — Session detail: full Helm chart upgrade cycle (ArgoCD v2→v3, External Secrets, Ceph CSI, CNPG, Velero), CNPG replica WAL-gap rebuild, Ceph CSI transient API server disruption, ExternalSecret OutOfSync root cause
|
||
- `references/gitea-k8s-migration-2026-07.md` — Gitea LXC→K8s migration plan: Galera over CNPG decision, Gitea Migration API for SQLite→MySQL, chicken-egg ArgoCD cutover, git.schoen.codes external URL
|
||
- `references/gitea-helm-chart-v12-deployment-2026-07.md` — Gitea Helm Chart v12 deployment: additionalConfigFromEnvs for DB password, valkey-cluster disabling, RWO PVC blocking, Galera schema slowness, ArgoCD app split
|
||
- `references/traefik-lxc-to-k8s-migration-2026-07.md` — Traefik CT99999 → K8s migration: service inventory, static/dynamic config, ExternalName pattern, catchall-TCP pitfall, cutover strategy
|
||
- `references/argocd-self-management-cutover-2026-07.md` — ArgoCD chicken-and-egg cutover: CoreDNS internal DNS override, repo secret labeling, Application URL patching, self-management verification
|
||
- `references/hermes-git-remote-cutover-2026-07.md` — Hermes host git remote switch from CT108 to K8s Gitea: /etc/hosts for external CoreDNS, token non-migration, Traefik VIP reachability, no kubectl on Hermes host
|
||
- `references/influxdb-k8s-migration-2026-07.md` — InfluxDB CT109+CT134 → K8s migration: fresh deploy + replication strategy (Option B chosen over backup/restore), Portainer stack discovery on 10.0.30.100, Cilium VIP .204 allocation, downsampling task string-field crash fix, Compound Engineering brainstorming-first workflow correction
|
||
- `references/gitea-backup-cronjob-2026-07.md` — Gitea backup CronJob: multi-initContainer pattern (kubectl exec tar for PVC + mysqldump for Galera DB), RBAC for pods/exec, S3 bucket per service, schedule staggering, bitnami/kubectl image pull failures, kubectl cp large file failures, ArgoCD false sync success, VM200 stale remote, mgmt-runner fallback
|
||
- `references/hermes-memory-system-architecture.md` — Hermes memory injection pipeline: MEMORY.md/USER.md loading, frozen snapshots, system prompt tiers, config limits, alternative context injection points (SOUL.md, AGENTS.md, .cursorrules), `bitnami/kubectl:1.31` tag不存在, `kubectl cp` large file websocket reset, VM200 repo remote staleness, S3 `--region us-east-1` for `s3 mb`, mgmt-runner fallback approach
|