---
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.

---

## 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.

**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.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
