|
| 1 | +#!/bin/sh |
| 2 | +# Imperative half of the kubernetes-proxmox suite. |
| 3 | +# |
| 4 | +# Chainsaw's declarative asserts cover everything that is a Kubernetes object in |
| 5 | +# THIS cluster, and the suite uses them for exactly that. What is left needs a |
| 6 | +# shell: the hypervisor is reached over SSH and answers in `qm` output, and the |
| 7 | +# tenant cluster is reached with a kubeconfig that only exists once its control |
| 8 | +# plane is up. Those are the two things below. |
| 9 | +# |
| 10 | +# Deliberately not a port of _lib/run-kubernetes.sh. That script's diagnostic |
| 11 | +# half is virt-launcher Pods, VirtualMachineInstances and guest consoles — |
| 12 | +# objects with no counterpart on an external hypervisor — so sharing it would |
| 13 | +# mean carrying a thousand lines that can never fire here. |
| 14 | +# |
| 15 | +# Every subcommand exits non-zero on failure and says which check failed. There |
| 16 | +# is no `|| echo` anywhere in this file on purpose: the pattern turns a failed |
| 17 | +# command into a green run, and this repository has been bitten by it. |
| 18 | +set -eu |
| 19 | + |
| 20 | +NS="${COZY_PROXMOX_NS:-tenant-e2e-proxmox}" |
| 21 | +CLUSTER="${COZY_PROXMOX_CLUSTER:-k8s-e2e}" |
| 22 | +PVE_SSH="${COZY_PVE_SSH:?COZY_PVE_SSH is required, e.g. root@192.168.20.1}" |
| 23 | +PVE_SSH_KEY="${COZY_PVE_SSH_KEY:-$HOME/.ssh/id_ed25519}" |
| 24 | +STORAGE="${COZY_PROXMOX_STORAGE:?COZY_PROXMOX_STORAGE is required, the PVE storage id for the PVC probe}" |
| 25 | +TENANT_KUBECONFIG="${TMPDIR:-/tmp}/proxmox-e2e-tenant.kubeconfig" |
| 26 | + |
| 27 | +log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; } |
| 28 | +die() { printf '[%s] FAILED: %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; exit 1; } |
| 29 | + |
| 30 | +pve() { ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$PVE_SSH_KEY" "$PVE_SSH" "$@"; } |
| 31 | + |
| 32 | +# The Machine carries the VM id in the ProxmoxMachine it references. Reading it |
| 33 | +# from the cluster rather than guessing keeps this working when a replacement |
| 34 | +# lands on a different id. |
| 35 | +vmid_of_machine() { |
| 36 | + kubectl -n "$NS" get proxmoxmachine -o jsonpath='{.items[0].spec.virtualMachineID}' |
| 37 | +} |
| 38 | + |
| 39 | +# --- checks ----------------------------------------------------------------- |
| 40 | + |
| 41 | +# A full clone is a copy of the template's disk; a linked clone references it and |
| 42 | +# stores only the increment. Nothing fails when this is wrong — the pool comes |
| 43 | +# up either way — so the only place it can be caught is here, by the shape of the |
| 44 | +# volume name: a linked clone points at `base-<template>-disk-N/`. |
| 45 | +check_linked_clone() { |
| 46 | + vmid=$(vmid_of_machine) |
| 47 | + [ -n "$vmid" ] || die "no ProxmoxMachine with a virtualMachineID in $NS" |
| 48 | + log "VM $vmid: checking the boot disk is a linked clone" |
| 49 | + |
| 50 | + bootdisk=$(pve "qm config $vmid" | sed -n 's/^scsi0: *//p') |
| 51 | + [ -n "$bootdisk" ] || die "VM $vmid has no scsi0" |
| 52 | + |
| 53 | + case "$bootdisk" in |
| 54 | + *:base-*-disk-*/*) log "linked clone confirmed: $bootdisk" ;; |
| 55 | + *) die "VM $vmid boot disk is a full copy, not a linked clone: $bootdisk" ;; |
| 56 | + esac |
| 57 | +} |
| 58 | + |
| 59 | +# The tenant kubeconfig exists only after Kamaji has issued it. Fetching it is |
| 60 | +# also the first proof that the control plane came up at all. |
| 61 | +fetch_tenant_kubeconfig() { |
| 62 | + kubectl -n "$NS" get secret "${CLUSTER}-admin-kubeconfig" \ |
| 63 | + -o jsonpath='{.data.super-admin\.svc}' | base64 -d > "$TENANT_KUBECONFIG" |
| 64 | + [ -s "$TENANT_KUBECONFIG" ] || die "tenant kubeconfig is empty — did the control plane come up?" |
| 65 | +} |
| 66 | + |
| 67 | +# providerID and the topology labels are applied by the cloud-controller-manager, |
| 68 | +# and the CSI node plugin refuses to start without the labels. Asserting them |
| 69 | +# here is what separates "the node joined" from "the node is usable". |
| 70 | +check_tenant_node() { |
| 71 | + fetch_tenant_kubeconfig |
| 72 | + log "checking the tenant node is Ready and initialised by the CCM" |
| 73 | + |
| 74 | + node=$(KUBECONFIG="$TENANT_KUBECONFIG" kubectl get nodes -o jsonpath='{.items[0].metadata.name}') |
| 75 | + [ -n "$node" ] || die "tenant cluster has no nodes" |
| 76 | + |
| 77 | + provider=$(KUBECONFIG="$TENANT_KUBECONFIG" kubectl get node "$node" -o jsonpath='{.spec.providerID}') |
| 78 | + region=$(KUBECONFIG="$TENANT_KUBECONFIG" kubectl get node "$node" \ |
| 79 | + -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/region}') |
| 80 | + |
| 81 | + [ -n "$provider" ] || die "node $node has no providerID — the CCM did not initialise it" |
| 82 | + [ -n "$region" ] || die "node $node has no topology region label — the CSI node plugin cannot start" |
| 83 | + log "node $node: providerID=$provider region=$region" |
| 84 | +} |
| 85 | + |
| 86 | +# Bound is not proof: a StorageClass can bind a volume that never becomes a disk. |
| 87 | +# The disk on the hypervisor is the proof, so the check reads `qm config` and |
| 88 | +# then removes what it created. |
| 89 | +check_pvc_round_trip() { |
| 90 | + fetch_tenant_kubeconfig |
| 91 | + vmid=$(vmid_of_machine) |
| 92 | + log "binding a PVC and looking for its disk on VM $vmid" |
| 93 | + |
| 94 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl apply -f - <<'EOF' |
| 95 | +apiVersion: v1 |
| 96 | +kind: PersistentVolumeClaim |
| 97 | +metadata: |
| 98 | + name: e2e-csi-probe |
| 99 | +spec: |
| 100 | + accessModes: [ReadWriteOnce] |
| 101 | + storageClassName: proxmox |
| 102 | + resources: |
| 103 | + requests: |
| 104 | + storage: 1Gi |
| 105 | +--- |
| 106 | +apiVersion: v1 |
| 107 | +kind: Pod |
| 108 | +metadata: |
| 109 | + name: e2e-csi-probe |
| 110 | +spec: |
| 111 | + containers: |
| 112 | + - name: c |
| 113 | + image: busybox:1.36 |
| 114 | + command: ["sh", "-c", "sleep 600"] |
| 115 | + volumeMounts: |
| 116 | + - name: v |
| 117 | + mountPath: /data |
| 118 | + volumes: |
| 119 | + - name: v |
| 120 | + persistentVolumeClaim: |
| 121 | + claimName: e2e-csi-probe |
| 122 | +EOF |
| 123 | + |
| 124 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl wait --for=condition=Ready pod/e2e-csi-probe --timeout=5m \ |
| 125 | + || die "the probe Pod never became Ready — the volume did not attach" |
| 126 | + |
| 127 | + if ! pve "qm config $vmid" | grep -q "^scsi[1-9].*$STORAGE:"; then |
| 128 | + pve "qm config $vmid" | sed 's/^/ qm-config: /' >&2 |
| 129 | + die "PVC bound but no disk is attached to VM $vmid on storage $STORAGE" |
| 130 | + fi |
| 131 | + log "disk attached on the hypervisor" |
| 132 | + |
| 133 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl delete pod e2e-csi-probe --wait=true |
| 134 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl delete pvc e2e-csi-probe --wait=true |
| 135 | +} |
| 136 | + |
| 137 | +# The regression this exists for: with a blanket toleration the CCM Pod outlived |
| 138 | +# the Node it ran on, and since that controller is what removes a departed Node, |
| 139 | +# a replacement deadlocked until an operator deleted the Node by hand. "Without |
| 140 | +# intervention" is the whole assertion, so nothing here touches the cluster |
| 141 | +# beyond the initial delete. |
| 142 | +check_replacement_converges() { |
| 143 | + deadline=$(( $(date +%s) + 900 )) |
| 144 | + old=$(kubectl -n "$NS" get machine -o jsonpath='{.items[0].metadata.name}') |
| 145 | + [ -n "$old" ] || die "no Machine to replace in $NS" |
| 146 | + log "deleting Machine $old and waiting for an unattended replacement" |
| 147 | + |
| 148 | + kubectl -n "$NS" delete machine "$old" --wait=false |
| 149 | + |
| 150 | + while [ "$(date +%s)" -lt "$deadline" ]; do |
| 151 | + name=$(kubectl -n "$NS" get machine -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) |
| 152 | + phase=$(kubectl -n "$NS" get machine -o jsonpath='{.items[0].status.phase}' 2>/dev/null || true) |
| 153 | + noderef=$(kubectl -n "$NS" get machine -o jsonpath='{.items[0].status.nodeRef.name}' 2>/dev/null || true) |
| 154 | + if [ -n "$name" ] && [ "$name" != "$old" ] && [ "$phase" = "Running" ] && [ -n "$noderef" ]; then |
| 155 | + log "replacement $name reached Running with nodeRef $noderef" |
| 156 | + return 0 |
| 157 | + fi |
| 158 | + sleep 15 |
| 159 | + done |
| 160 | + |
| 161 | + kubectl -n "$NS" get machine,machinedeployment -o wide >&2 || true |
| 162 | + die "no replacement reached Running with a nodeRef within 15m — the CCM Pod is probably stranded on the departed Node" |
| 163 | +} |
| 164 | + |
| 165 | +# Runs from the suite's catch. Cheap reads first, so a truncated dump still |
| 166 | +# carries the ones that discriminate between failure modes. |
| 167 | +dump_diagnostics() { |
| 168 | + log "=== Machines and their infrastructure ===" |
| 169 | + kubectl -n "$NS" get machine,proxmoxmachine,machinedeployment,proxmoxcluster -o wide || true |
| 170 | + log "=== capmox controller ===" |
| 171 | + kubectl -n cozy-cluster-api logs -l control-plane=controller-manager --tail=80 \ |
| 172 | + --selector=cluster.x-k8s.io/provider=infrastructure-proxmox || true |
| 173 | + log "=== VMs on the hypervisor ===" |
| 174 | + pve "qm list" || true |
| 175 | + if [ -s "$TENANT_KUBECONFIG" ]; then |
| 176 | + log "=== tenant nodes and pods ===" |
| 177 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl get nodes -o wide || true |
| 178 | + KUBECONFIG="$TENANT_KUBECONFIG" kubectl get pods -A -o wide || true |
| 179 | + fi |
| 180 | +} |
| 181 | + |
| 182 | +case "${1:-}" in |
| 183 | + linked-clone) check_linked_clone ;; |
| 184 | + tenant-node) check_tenant_node ;; |
| 185 | + pvc-round-trip) check_pvc_round_trip ;; |
| 186 | + replacement) check_replacement_converges ;; |
| 187 | + diagnostics) dump_diagnostics ;; |
| 188 | + *) die "usage: $0 {linked-clone|tenant-node|pvc-round-trip|replacement|diagnostics}" ;; |
| 189 | +esac |
0 commit comments