docs(longhorn): document 2026-04-13 power-cut recovery + add data-recovery tooling

Captures the post-mortem of the April 13 power-cut: incident timeline,
retrospective, and architecture/role diagrams. Adds an ADR explaining why
Longhorn cannot re-associate orphaned replica directories after a nuclear
reinstall (engine-id naming), plus block-device recovery runbooks and the
`playbooks/recover/longhorn_data.yml` automation that wires `merge-longhorn-layers.py`
to rebuild PVCs from raw `volume-head-*.img` chains.

Also extends the k3s_pvc backup to capture Longhorn `volumes`/`settings` CRDs
(needed for the fast-path restore) and rewrites the restore script with a
fallback dir + English messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
2026-05-06 12:55:18 +02:00
co-authored by Claude Opus 4.7
parent 934b62d922
commit 1ae28cb944
20 changed files with 5939 additions and 10 deletions
@@ -24,12 +24,15 @@
- name: define backup command
set_fact:
backup_cmd: |-
echo "
$(kubectl get -A pv -o yaml)
---
$(kubectl get -A pvc -o yaml)
"
# PVs + PVCs + Longhorn Volume CRDs (critical for fast recovery — without Volume CRDs,
# Longhorn cannot re-associate orphaned replica dirs after a reinstall and forces
# full block-device injection recovery. See docs/adr/20260414-longhorn-pvc-recovery.md)
backup_cmd: >-
kubectl get -A pv,pvc -o yaml
&& echo '---'
&& kubectl get -A volumes.longhorn.io -o yaml
&& echo '---'
&& kubectl get -A settings.longhorn.io -o yaml
- name: test backup_cmd
ansible.builtin.shell: |
@@ -65,19 +68,34 @@
#!/bin/bash
set -e
BACKUP_DIR="{{ backup_dir }}"
PRIMARY_BACKUP_DIR="{{ backup_dir }}"
FALLBACK_BACKUP_DIR="/home/pi/arcodange/backups/k3s_pvc"
# Check if fallback directory exists and has backups
if [ -d "$FALLBACK_BACKUP_DIR" ] && ls "$FALLBACK_BACKUP_DIR"/*.volumes 1>/dev/null 2>&1; then
BACKUP_DIR="$FALLBACK_BACKUP_DIR"
echo "Using fallback backup directory: $BACKUP_DIR"
elif [ -d "$PRIMARY_BACKUP_DIR" ] && ls "$PRIMARY_BACKUP_DIR"/*.volumes 1>/dev/null 2>&1; then
BACKUP_DIR="$PRIMARY_BACKUP_DIR"
else
echo "No backup directory found"
exit 1
fi
if [ -z "$1" ]; then
FILE=$(ls -1t "$BACKUP_DIR"/backup_*.volumes | head -n 1)
echo "Aucune date fournie, restauration du dernier dump : $FILE"
echo "No date provided, restoring latest dump: $FILE"
else
FILE="$BACKUP_DIR/backup_$1.volumes"
if [ ! -f "$FILE" ]; then
echo "Fichier $FILE introuvable"
echo "File $FILE not found"
exit 1
fi
fi
kubectl apply -f "$FILE"
echo "Restauration des volumes k3s terminée."
echo "K3S volumes restoration complete."
echo "NOTE: file includes PVs, PVCs, and Longhorn Volume CRDs."
echo "If Longhorn replica dirs are still orphaned after this restore,"
echo "fall back to: ansible-playbook playbooks/recover/longhorn_data.yml"
@@ -0,0 +1,536 @@
---
- name: Recover Longhorn from Power Cut - CSI Driver Registration Loss
hosts: raspberries:&local
gather_facts: yes
become: yes
vars:
# Backup locations
primary_backup_dir: "/mnt/backups/k3s_pvc"
fallback_backup_dir: "/home/pi/arcodange/backups/k3s_pvc"
scripts_dir: "/opt/k3s_volumes"
# Longhorn configuration
longhorn_manifest_path: "/var/lib/rancher/k3s/server/manifests/longhorn-install.yaml"
longhorn_namespace: "longhorn-system"
longhorn_chart_name: "longhorn-install"
longhorn_chart_namespace: "kube-system"
# Data paths (DO NOT MODIFY - points to actual volume data)
longhorn_data_path: "/mnt/arcodange/longhorn"
tasks:
# ========================================================================
# PHASE 0: Pre-flight Checks
# ========================================================================
- name: Verify data directory exists on control plane
ansible.builtin.stat:
path: "{{ longhorn_data_path }}"
register: data_dir
when: inventory_hostname == 'pi1'
run_once: true
- name: FAIL if data directory missing
ansible.builtin.fail:
msg: "CRITICAL: Longhorn data directory {{ longhorn_data_path }} does not exist. Aborting recovery."
when: inventory_hostname == 'pi1' and not data_dir.stat.exists
run_once: true
- name: Check for fallback backups on pi1
ansible.builtin.shell: ls {{ fallback_backup_dir }}/backup_*.volumes 2>/dev/null
register: fallback_backup_check
changed_when: false
when: inventory_hostname == 'pi1'
run_once: true
ignore_errors: yes
- name: Check for primary backups on pi1
ansible.builtin.shell: ls {{ primary_backup_dir }}/backup_*.volumes 2>/dev/null
register: primary_backup_check
changed_when: false
when: inventory_hostname == 'pi1'
run_once: true
ignore_errors: yes
- name: Set backup fact
ansible.builtin.set_fact:
has_backups: "{{ (fallback_backup_check.rc == 0 and fallback_backup_check.stdout | trim != '') or (primary_backup_check.rc == 0 and primary_backup_check.stdout | trim != '') }}"
when: inventory_hostname == 'pi1'
run_once: true
- name: FAIL if no backups found
ansible.builtin.fail:
msg: "No backup files found in {{ primary_backup_dir }} or {{ fallback_backup_dir }}. Cannot proceed."
when: inventory_hostname == 'pi1' and not has_backups | bool
run_once: true
# ========================================================================
# PHASE 1: Diagnosis - Check Current State
# ========================================================================
- name: Gather Longhorn namespace status
block:
- name: Check if longhorn-system namespace exists
kubernetes.core.k8s_info:
kind: Namespace
name: "{{ longhorn_namespace }}"
register: longhorn_ns
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Check CSI driver registration
kubernetes.core.k8s_info:
kind: CSIDriver
name: driver.longhorn.io
register: csi_driver
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Check Longhorn manager pods
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-manager
register: managers
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Set recovery_phase fact
ansible.builtin.set_fact:
recovery_phase: "none"
run_once: true
delegate_to: localhost
- name: Determine recovery phase needed
ansible.builtin.set_fact:
recovery_phase: >-
{% if csi_driver.failed %}
soft
{% elif managers.failed or managers.resources | default([]) | selectattr('status.phase', 'defined') | selectattr('status.phase', 'ne', 'Running') | list | length > 0 %}
hard
{% elif longhorn_ns.failed %}
none
{% else %}
none
{% endif %}
run_once: true
delegate_to: localhost
- name: Display recovery diagnosis
ansible.builtin.debug:
msg: "Diagnosis: recovery_phase={{ recovery_phase | default('none') }}. CSI Driver exists: {{ not csi_driver.failed | bool }}, Managers healthy: {{ managers.failed | ternary('unknown', managers.resources | default([]) | selectattr('status.phase', 'defined') | selectattr('status.phase', 'eq', 'Running') | list | length >= 3) | bool }}"
run_once: true
delegate_to: localhost
when: inventory_hostname == 'pi1'
run_once: true
# ========================================================================
# PHASE 2: Soft Recovery - Touch Manifest
# ========================================================================
- name: Execute soft recovery - touch Longhorn manifest
block:
- name: Touch longhorn-install.yaml manifest
ansible.builtin.file:
path: "{{ longhorn_manifest_path }}"
state: touch
register: manifest_touch
when: inventory_hostname == 'pi1'
- name: Wait for k3s to detect manifest change
ansible.builtin.pause:
minutes: 1
when: manifest_touch is changed
- name: Check if Longhorn pods are recreating
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
register: longhorn_pods
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Verify soft recovery success
ansible.builtin.set_fact:
soft_recovery_success: >-
{{ (longhorn_pods.resources | default([]) | selectattr('metadata.creationTimestamp', 'defined') | list | length) >= 10 }}
run_once: true
delegate_to: localhost
when: recovery_phase == 'soft' and inventory_hostname == 'pi1'
run_once: true
# ========================================================================
# PHASE 3: Hard Recovery - Delete Driver-Deployer
# ========================================================================
- name: Execute hard recovery - delete driver-deployer pods
block:
- name: Get driver-deployer pods
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-driver-deployer
register: driver_deployer_pods
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Delete driver-deployer pods
kubernetes.core.k8s:
state: absent
kind: Pod
namespace: "{{ longhorn_namespace }}"
name: "{{ item.metadata.name }}"
force: yes
grace_period: 0
loop: "{{ driver_deployer_pods.resources | default([]) }}"
when: driver_deployer_pods.resources | default([]) | length > 0
run_once: true
delegate_to: localhost
- name: Wait for HelmChart to recreate driver-deployer
ansible.builtin.pause:
minutes: 2
- name: Check driver-deployer status
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-driver-deployer
register: new_driver_deployer
ignore_errors: yes
run_once: true
delegate_to: localhost
when: (recovery_phase == 'hard' or (recovery_phase == 'soft' and not soft_recovery_success | default(false))) and inventory_hostname == 'pi1'
run_once: true
# ========================================================================
# PHASE 4: Nuclear Recovery - Full Reinstall
# ========================================================================
- name: Execute nuclear recovery - full Longhorn reinstall
block:
# Step 1: Delete HelmChart
- name: Delete Longhorn HelmChart
kubernetes.core.k8s:
state: absent
kind: HelmChart
namespace: "{{ longhorn_chart_namespace }}"
name: "{{ longhorn_chart_name }}"
force: yes
grace_period: 0
register: helmchart_deleted
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Wait for HelmChart to be fully removed
ansible.builtin.pause:
seconds: 30
when: helmchart_deleted is changed
run_once: true
# Step 2: Remove Longhorn manifest from filesystem
- name: Remove Longhorn manifest file
ansible.builtin.file:
path: "{{ longhorn_manifest_path }}"
state: absent
when: inventory_hostname == 'pi1'
register: manifest_removed
# Step 3: Remove finalizers from all Longhorn resources
- name: Get list of all Longhorn CRDs
kubernetes.core.k8s_info:
kind: CustomResourceDefinition
label_selectors:
- app=longhorn
register: longhorn_crds
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Get all Longhorn CR instances
kubernetes.core.k8s_info:
kind: "{{ item.spec.names.kind }}"
namespace: "{{ longhorn_namespace }}"
api_version: "{{ item.spec.group ~ '/' ~ item.spec.versions[0].name }}"
register: cr_instances
ignore_errors: yes
loop: "{{ longhorn_crds.resources | default([]) }}"
run_once: true
delegate_to: localhost
- name: Remove finalizers from all Longhorn CR instances
kubernetes.core.k8s_json_patch:
kind: "{{ item.0.spec.names.kind }}"
namespace: "{{ longhorn_namespace }}"
name: "{{ item.1.metadata.name }}"
api_version: "{{ item.0.spec.group ~ '/' ~ item.0.spec.versions[0].name }}"
patch:
- op: replace
path: /metadata/finalizers
value: []
loop: >-
{% set results = [] %}
{% for crd in longhorn_crds.resources | default([]) %}
{% for instance in hostvars['localhost']['cr_instances'].results | default([]) %}
{% if instance.crd == crd %}
{% set results = results.append([crd, instance.resources[0] if instance.resources else {}]) %}
{% endif %}
{% endfor %}
{% endfor %}
{{ results }}
when: cr_instances.results | default([]) | length > 0
run_once: true
delegate_to: localhost
ignore_errors: yes
# Step 4: Remove finalizers from PVCs
- name: Get all PVCs with longhorn storage class
kubernetes.core.k8s_info:
kind: PersistentVolumeClaim
register: all_pvcs
ignore_errors: yes
run_once: true
delegate_to: localhost
- name: Remove finalizers from PVCs
kubernetes.core.k8s_json_patch:
kind: PersistentVolumeClaim
namespace: "{{ item.metadata.namespace }}"
name: "{{ item.metadata.name }}"
patch:
- op: replace
path: /metadata/finalizers
value: []
loop: "{{ all_pvcs.resources | default([]) | selectattr('spec.storageClassName', 'defined') | selectattr('spec.storageClassName', 'match', 'longhorn.*') | list }}"
run_once: true
delegate_to: localhost
ignore_errors: yes
# Step 5: Remove namespace finalizers
- name: Remove finalizers from longhorn-system namespace
kubernetes.core.k8s_json_patch:
kind: Namespace
name: "{{ longhorn_namespace }}"
patch:
- op: replace
path: /spec/finalizers
value: []
run_once: true
delegate_to: localhost
ignore_errors: yes
- name: Delete longhorn-system namespace
kubernetes.core.k8s:
state: absent
kind: Namespace
name: "{{ longhorn_namespace }}"
force: yes
grace_period: 0
run_once: true
delegate_to: localhost
ignore_errors: yes
- name: Wait for namespace deletion
ansible.builtin.pause:
seconds: 15
run_once: true
# Step 6: Reinstall Longhorn via manifest
- name: Deploy Longhorn HelmChart manifest
ansible.builtin.copy:
dest: "{{ longhorn_manifest_path }}"
content: |
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
annotations:
helmcharts.cattle.io/managed-by: helm-controller
finalizers:
- wrangler.cattle.io/on-helm-chart-remove
name: longhorn-install
namespace: kube-system
spec:
version: v1.9.1
chart: longhorn
repo: https://charts.longhorn.io
failurePolicy: abort
targetNamespace: longhorn-system
createNamespace: true
valuesContent: |-
defaultSettings:
defaultDataPath: {{ longhorn_data_path }}
when: inventory_hostname == 'pi1'
register: manifest_deployed
- name: Trigger k3s reconcile by touching manifest
ansible.builtin.file:
path: "{{ longhorn_manifest_path }}"
state: touch
when: manifest_deployed is changed and inventory_hostname == 'pi1'
- name: Wait for Longhorn pods to be created
ansible.builtin.pause:
minutes: 3
when: manifest_deployed is changed
run_once: true
when: >-
(recovery_phase == 'hard' and not new_driver_deployer.resources | default([]) | selectattr('status.phase', 'eq', 'Running') | list | length > 0)
or (recovery_phase == 'soft' and not soft_recovery_success | default(false) and not new_driver_deployer.resources | default([]) | selectattr('status.phase', 'eq', 'Running') | list | length > 0)
or recovery_phase == 'none'
run_once: true
# ========================================================================
# PHASE 5: Restore from Backup
# ========================================================================
- name: Execute restore from backup
block:
- name: Determine backup directory to use
ansible.builtin.set_fact:
backup_dir_to_use: >-
{% if fallback_backup_dir and lookup('fileglob', fallback_backup_dir ~ '/backup_*.volumes') | length > 0 %}
{{ fallback_backup_dir }}
{% elif primary_backup_dir and lookup('fileglob', primary_backup_dir ~ '/backup_*.volumes') | length > 0 %}
{{ primary_backup_dir }}
{% else %}
""
{% endif %}
run_once: true
delegate_to: localhost
- name: FAIL if no backup directory found
ansible.builtin.fail:
msg: "No valid backup directory found with backup_*.volumes files"
when: backup_dir_to_use == ""
run_once: true
- name: Find latest backup file
ansible.builtin.set_fact:
latest_backup: >-
{% set files = lookup('fileglob', backup_dir_to_use ~ '/backup_*.volumes', wantlist=True) | sort(attribute='stat.mtime', reverse=True) %}
{% if files | length > 0 %}
{{ files[0].path }}
{% endif %}
run_once: true
delegate_to: localhost
- name: FAIL if no backup files found
ansible.builtin.fail:
msg: "No backup files found in {{ backup_dir_to_use }}"
when: latest_backup | default('') == ''
run_once: true
- name: Wait for Longhorn managers to be ready
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-manager
register: managers_status
until: >-
{{ (managers_status.resources | default([]) | selectattr('status.phase', 'eq', 'Running') | list | length) >= 1 }}
retries: 30
delay: 10
run_once: true
delegate_to: localhost
- name: Apply PV/PVC backup
kubernetes.core.k8s:
state: present
src: "{{ latest_backup }}"
run_once: true
delegate_to: localhost
- name: Find Longhorn metadata backup
ansible.builtin.set_fact:
longhorn_backup: >-
{% set lh_files = lookup('fileglob', backup_dir_to_use ~ '/longhorn_metadata_*.yaml', wantlist=True) | sort(attribute='stat.mtime', reverse=True) %}
{% if lh_files | length > 0 %}
{{ lh_files[0].path }}
{% endif %}
run_once: true
delegate_to: localhost
- name: Apply Longhorn metadata backup (if exists)
kubernetes.core.k8s:
state: present
src: "{{ longhorn_backup | default(omit) }}"
namespace: "{{ longhorn_namespace }}"
when: longhorn_backup | default('') != ''
run_once: true
delegate_to: localhost
when: inventory_hostname == 'pi1'
run_once: true
# ========================================================================
# PHASE 6: Post-Recovery Verification
# ========================================================================
- name: Verify recovery success
block:
- name: Check CSI driver registration
kubernetes.core.k8s_info:
kind: CSIDriver
name: driver.longhorn.io
register: csi_final
until: csi_final.resources | length > 0
retries: 10
delay: 10
run_once: true
delegate_to: localhost
- name: Check Longhorn manager health
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-manager
register: managers_final
until: >-
{{ (managers_final.resources | default([]) | selectattr('status.phase', 'eq', 'Running') | list | length) >= 3 }}
retries: 15
delay: 10
run_once: true
delegate_to: localhost
- name: Check CSI socket exists (on pi1)
ansible.builtin.stat:
path: /var/lib/kubelet/plugins/driver.longhorn.io/csi.sock
register: csi_socket
when: inventory_hostname == 'pi1'
- name: Verify volume data is still present
ansible.builtin.stat:
path: "{{ longhorn_data_path }}/replicas"
register: replicas_dir
when: inventory_hostname == 'pi1'
- name: Display recovery summary
ansible.builtin.debug:
msg: |
===== Longhorn Recovery Summary =====
CSI Driver Registered: {{ not csi_final.failed | bool | ternary('✓', '✗') }}
Managers Running: {{ (managers_final.resources | default([]) | selectattr('status.phase', 'eq', 'Running') | list | length) }}/3
CSI Socket Exists: {{ csi_socket.stat.exists | default(false) | bool | ternary('✓', '✗') }}
Volume Data Present: {{ replicas_dir.stat.exists | default(false) | bool | ternary('✓', '✗') }}
Backup Used: {{ latest_backup | default('none') }}
======================================
run_once: true
when: inventory_hostname == 'pi1'
run_once: true
@@ -0,0 +1,914 @@
---
# Longhorn Block-Device Data Recovery Playbook
#
# PURPOSE:
# Recover application data directly from raw Longhorn replica files when Volume CRDs
# are missing (e.g. after a nuclear cleanup + reinstall). Bypasses k8s objects entirely
# and works at the block-device level.
#
# WHEN TO USE:
# - Longhorn has been fully reinstalled (Volume CRDs are gone)
# - Application PVCs are stuck Terminating / Lost
# - The raw replica .img files still exist on disk
# → See docs/runbooks/longhorn-block-device-recovery.md for the manual equivalent
#
# WHEN NOT TO USE:
# - Volume CRDs still exist → use playbooks/recover/longhorn.yml instead
# - All replica dirs were zeroed by Longhorn reconciliation (data is unrecoverable)
#
# USAGE:
# ansible-playbook -i inventory/hosts.yml playbooks/recover/longhorn_data.yml \
# -e @vars/recovery_volumes.yml
#
# VARS FILE FORMAT (vars/recovery_volumes.yml):
# longhorn_recovery_volumes:
# - pv_name: pvc-abc123 # Longhorn volume name (== PV name)
# pvc_name: myapp-data # PVC name in the namespace
# namespace: myapp # namespace where the PVC lives
# size_bytes: "134217728" # volume size in bytes (string)
# size_human: 128Mi # human-readable, used in PVC spec
# access_mode: ReadWriteOnce # ReadWriteOnce or ReadWriteMany
# workload_kind: Deployment # Deployment or StatefulSet
# workload_name: myapp # name of the workload to scale down/up
# source_node: pi3 # [OPTIONAL] node with untouched replica dir
# source_dir: pvc-abc123-998f49ff # [OPTIONAL] exact replica dir name
# verify_cmd: "" # optional: command to run inside pod to verify data after recovery
#
# source_node and source_dir are auto-discovered (largest dir >16K across all nodes)
# when not specified. Override manually only to force a specific replica dir.
#
# REQUIREMENTS:
# - python3 on all cluster nodes
# - kubectl configured on the Ansible controller (localhost)
# - longhorn-system namespace running and healthy before this playbook starts
# - kubernetes.core collection: ansible-galaxy collection install kubernetes.core
#
# TESTED SCENARIO:
# 2026-04-13 power cut — nuclear Longhorn reinstall — url-shortener SQLite recovery
# Proven working as of 2026-04-14.
- name: Longhorn Block-Device Data Recovery
hosts: localhost
gather_facts: no
vars:
longhorn_data_path: /mnt/arcodange/longhorn
longhorn_namespace: longhorn-system
longhorn_nodes: [pi1, pi2, pi3]
merge_tool_local: "{{ playbook_dir }}/../../docs/incidents/2026-04-13-power-cut/tools/merge-longhorn-layers.py"
merge_tool_remote: /home/pi/merge-longhorn-layers.py
backup_base: /home/pi/arcodange/backups/longhorn-recovery
merged_base: /tmp/longhorn-recovery-merged
recovery_mount: /mnt/recovery-src
live_mount: /mnt/recovery-live
longhorn_recovery_volumes: [] # override with -e @vars/recovery_volumes.yml
tasks:
# =========================================================================
# PRE-FLIGHT
# =========================================================================
- name: "Pre-flight | Fail fast if no volumes defined"
ansible.builtin.fail:
msg: >
No recovery volumes defined. Pass -e @vars/recovery_volumes.yml with a
longhorn_recovery_volumes list. See playbook header for format.
when: longhorn_recovery_volumes | length == 0
- name: "Pre-flight | Verify merge tool exists locally"
ansible.builtin.stat:
path: "{{ merge_tool_local }}"
register: merge_tool_stat
delegate_to: localhost
- name: "Pre-flight | Fail if merge tool missing"
ansible.builtin.fail:
msg: "merge-longhorn-layers.py not found at {{ merge_tool_local }}"
when: not merge_tool_stat.stat.exists
- name: "Pre-flight | Check Longhorn is healthy"
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ longhorn_namespace }}"
label_selectors:
- app=longhorn-manager
register: lh_managers
delegate_to: localhost
- name: "Pre-flight | Fail if Longhorn managers are not running"
ansible.builtin.fail:
msg: >
Longhorn managers not running (found {{ lh_managers.resources | default([]) |
selectattr('status.phase', 'eq', 'Running') | list | length }} Running pods).
Ensure Longhorn is healthy before attempting data recovery.
when: >
(lh_managers.resources | default([]) |
selectattr('status.phase', 'eq', 'Running') | list | length) < 1
- name: "Pre-flight | Summary"
ansible.builtin.debug:
msg: >
Longhorn healthy ({{ lh_managers.resources |
selectattr('status.phase', 'eq', 'Running') | list | length }} managers running).
Recovering {{ longhorn_recovery_volumes | length }} volume(s):
{{ longhorn_recovery_volumes | map(attribute='pv_name') | list | join(', ') }}
# =========================================================================
# PHASE 0 — AUTO-DISCOVER BEST REPLICA DIR (when source_node/source_dir absent)
# =========================================================================
- name: "Phase 0 | Scan replica dirs on all nodes"
ansible.builtin.shell: |
result=""
for dir in {{ longhorn_data_path }}/replicas/{{ item.1.pv_name }}-*; do
[ -d "$dir" ] || continue
# Skip replicas that were being rebuilt — their data is incomplete
meta="$dir/volume.meta"
if [ -f "$meta" ]; then
rebuilding=$(python3 -c "import json; d=json.load(open('$meta')); print(d.get('Rebuilding', False))" 2>/dev/null)
[ "$rebuilding" = "True" ] && continue
fi
# Use actual disk usage (not apparent/sparse size) to rank replicas
size=$(du -sk "$dir" 2>/dev/null | cut -f1)
name=$(basename "$dir")
result="$result\n$size $name"
done
printf '%b' "$result" | grep -v '^$' || true
delegate_to: "{{ item.0 }}"
become: yes
loop: "{{ longhorn_nodes | product(longhorn_recovery_volumes) | list }}"
loop_control:
label: "{{ item.0 }}: {{ item.1.pv_name }}"
register: dir_scan_raw
changed_when: false
when: item.1.source_node | default('') == '' or item.1.source_dir | default('') == ''
- name: "Phase 0 | Pick best source (largest dir with data, >16K)"
ansible.builtin.set_fact:
_discovered_sources: "{{ _build | from_json }}"
vars:
_build: >-
{% set ns = namespace(result={}) %}
{% for res in dir_scan_raw.results | default([]) %}
{% if not res.skipped | default(false) and res.stdout | default('') != '' %}
{% set node = res.item.0 %}
{% set vol = res.item.1.pv_name %}
{% for line in res.stdout_lines %}
{% set parts = line.split() %}
{% if parts | length == 2 %}
{% set size = parts[0] | int %}
{% set dir = parts[1] %}
{% if size > 16384 and (vol not in ns.result or size > ns.result[vol].size) %}
{# size is in KB (from du -sk); 16384 KB = 16 MiB minimum real replica #}
{% set _ = ns.result.update({vol: {'node': node, 'dir': dir, 'size': size}}) %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{{ ns.result | to_json }}
- name: "Phase 0 | Show discovered sources"
ansible.builtin.debug:
msg: >-
{% for vol in longhorn_recovery_volumes %}
{{ vol.pv_name }}:
{% if vol.source_node | default('') != '' %}
source: MANUAL → {{ vol.source_node }}/{{ vol.source_dir }}
{% elif vol.pv_name in _discovered_sources %}
source: AUTO → {{ _discovered_sources[vol.pv_name].node }}/{{ _discovered_sources[vol.pv_name].dir }}
({{ (_discovered_sources[vol.pv_name].size / 1024 / 1024) | round(0) | int }} MiB)
{% else %}
source: NOT FOUND — no dir >16K on any node for this volume
{% endif %}
{% endfor %}
- name: "Phase 0 | Fail if source not found for any volume"
ansible.builtin.fail:
msg: >
No replica dir with data found for {{ item.pv_name }} on any node
({{ longhorn_nodes | join(', ') }}). Check that the replica files survived.
loop: "{{ longhorn_recovery_volumes }}"
loop_control:
label: "{{ item.pv_name }}"
when: >
item.source_node | default('') == '' and
item.source_dir | default('') == '' and
item.pv_name not in _discovered_sources
- name: "Phase 0 | Initialize merged volume list"
ansible.builtin.set_fact:
_merged_volumes: []
- name: "Phase 0 | Append each volume with resolved source"
ansible.builtin.set_fact:
_merged_volumes: "{{ _merged_volumes + [item | combine(_source)] }}"
vars:
_manual: "{{ item.source_node | default('') != '' and item.source_dir | default('') != '' }}"
_source: "{{ _manual | bool | ternary(
{'source_node': item.source_node, 'source_dir': item.source_dir},
{'source_node': _discovered_sources[item.pv_name].node,
'source_dir': _discovered_sources[item.pv_name].dir}) }}"
loop: "{{ longhorn_recovery_volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 0 | Apply resolved volume list"
ansible.builtin.set_fact:
_volumes: "{{ _merged_volumes }}"
# =========================================================================
# PHASE 1 — UPLOAD MERGE TOOL AND BACK UP REPLICA DIRS
# =========================================================================
- name: "Phase 1 | Upload merge tool to source nodes"
ansible.builtin.command: >
scp -o StrictHostKeyChecking=no
{{ merge_tool_local }}
pi@{{ item.source_node }}.home:{{ merge_tool_remote }}
delegate_to: localhost
become: no
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }} → {{ item.source_node }}"
changed_when: true
- name: "Phase 1 | Create backup directory on source node"
ansible.builtin.file:
path: "{{ backup_base }}/{{ item.pvc_name }}"
state: directory
mode: "0755"
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
- name: "Phase 1 | Check if backup already exists (skip if re-running)"
ansible.builtin.stat:
path: "{{ backup_base }}/{{ item.pvc_name }}/{{ item.source_dir }}/volume.meta"
register: backup_exists
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
- name: "Phase 1 | Back up untouched replica dir (safe copy before any operation)"
ansible.builtin.shell: >
cp -a {{ longhorn_data_path }}/replicas/{{ item.item.source_dir }}
{{ backup_base }}/{{ item.item.pvc_name }}/
delegate_to: "{{ item.item.source_node }}"
become: yes
loop: "{{ backup_exists.results }}"
loop_control:
label: "{{ item.item.pvc_name }}"
when: not item.stat.exists
changed_when: true
- name: "Phase 1 | Verify backup contains volume.meta"
ansible.builtin.stat:
path: "{{ backup_base }}/{{ item.pvc_name }}/{{ item.source_dir }}/volume.meta"
register: backup_meta
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
- name: "Phase 1 | Fail if backup is incomplete"
ansible.builtin.fail:
msg: >
Backup for {{ item.item.pvc_name }} is missing volume.meta — the source dir
{{ item.item.source_dir }} may not exist or backup copy failed.
loop: "{{ backup_meta.results }}"
loop_control:
label: "{{ item.item.pvc_name }}"
when: not item.stat.exists
# =========================================================================
# PHASE 2 — RECONSTRUCT FILESYSTEMS FROM REPLICA LAYERS
# =========================================================================
- name: "Phase 2 | Create merged output directory"
ansible.builtin.file:
path: "{{ merged_base }}"
state: directory
mode: "0755"
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
- name: "Phase 2 | Check if merged image already exists"
ansible.builtin.stat:
path: "{{ merged_base }}/{{ item.pvc_name }}.img"
register: merged_exists
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
- name: "Phase 2 | Merge snapshot + head layers into single image"
ansible.builtin.command: >
python3 {{ merge_tool_remote }}
{{ backup_base }}/{{ item.item.pvc_name }}/{{ item.item.source_dir }}
{{ merged_base }}/{{ item.item.pvc_name }}.img
delegate_to: "{{ item.item.source_node }}"
become: yes
loop: "{{ merged_exists.results }}"
loop_control:
label: "{{ item.item.pvc_name }}"
when: not item.stat.exists
changed_when: true
register: merge_output
- name: "Phase 2 | Show merge output"
ansible.builtin.debug:
msg: "{{ item.stdout_lines | default([]) }}"
loop: "{{ merge_output.results | default([]) }}"
loop_control:
label: "{{ item.item.item.pvc_name | default('') }}"
when: item.stdout_lines is defined
- name: "Phase 2 | Test mount merged image to verify filesystem"
ansible.builtin.shell: |
mkdir -p {{ recovery_mount }}-{{ item.pvc_name }}
mount -o loop,ro,noload {{ merged_base }}/{{ item.pvc_name }}.img {{ recovery_mount }}-{{ item.pvc_name }}
ls {{ recovery_mount }}-{{ item.pvc_name }}/
umount {{ recovery_mount }}-{{ item.pvc_name }}
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
register: mount_test
changed_when: false
- name: "Phase 2 | Show filesystem contents"
ansible.builtin.debug:
msg: "{{ item.item.pvc_name }}: {{ item.stdout_lines }}"
loop: "{{ mount_test.results }}"
loop_control:
label: "{{ item.item.pvc_name }}"
# =========================================================================
# PHASE 3 — CREATE LONGHORN VOLUME CRDs
# =========================================================================
# Scale down StatefulSets BEFORE removing PVC finalizers.
# StatefulSet controllers auto-recreate PVCs as soon as they are deleted; if we
# remove finalizers while the StatefulSet is still running, the controller
# immediately provisions a new empty PVC (bound to a fresh volume), making the
# PVC spec immutable by the time Phase 8 tries to pin it to our recovered PV.
# Deployments are less urgent here but scaled early for consistency.
- name: "Phase 3 | Pre-scale down Deployments (before PVC finalizer removal)"
kubernetes.core.k8s_scale:
kind: Deployment
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 0
wait: yes
wait_timeout: 60
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'Deployment' and item.workload_name != ''
ignore_errors: yes
- name: "Phase 3 | Pre-scale down StatefulSets (before PVC finalizer removal)"
kubernetes.core.k8s_scale:
kind: StatefulSet
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 0
wait: yes
wait_timeout: 60
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'StatefulSet' and item.workload_name != ''
ignore_errors: yes
# Clear any stuck Terminating PVs/PVCs BEFORE creating Volume CRDs.
# If old Terminating PVCs still exist when we create the Volume CRD, Longhorn
# associates them and deletes the Volume CRD when the PVC finishes terminating.
- name: "Phase 3 | Check PVC state before touching finalizers"
ansible.builtin.shell: >
kubectl get pvc {{ item.pvc_name }} -n {{ item.namespace }}
-o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true
register: pvc_deletion_ts
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.pvc_name }}"
changed_when: false
- name: "Phase 3 | Remove finalizers from stuck PV (if Terminating)"
ansible.builtin.shell: >
kubectl patch pv {{ item.pv_name }} --type=merge
-p '{"metadata":{"finalizers":null}}' 2>/dev/null || true
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
changed_when: false
- name: "Phase 3 | Remove finalizers from stuck PVC (if Terminating)"
ansible.builtin.shell: >
kubectl patch pvc {{ item.pvc_name }} -n {{ item.namespace }}
--type=merge -p '{"metadata":{"finalizers":null}}' 2>/dev/null || true
delegate_to: localhost
loop: "{{ pvc_deletion_ts.results }}"
loop_control:
label: "{{ item.item.namespace }}/{{ item.item.pvc_name }}"
when: item.stdout != ''
changed_when: false
- name: "Phase 3 | Wait for stuck PVCs to fully delete before creating Volume CRDs"
kubernetes.core.k8s_info:
kind: PersistentVolumeClaim
name: "{{ item.item.pvc_name }}"
namespace: "{{ item.item.namespace }}"
register: pvc_pre_check
until: pvc_pre_check.resources | default([]) | length == 0
retries: 12
delay: 5
delegate_to: localhost
loop: "{{ pvc_deletion_ts.results }}"
loop_control:
label: "{{ item.item.namespace }}/{{ item.item.pvc_name }}"
when: item.stdout != ''
- name: "Phase 3 | Check if Longhorn Volume CRD already exists"
kubernetes.core.k8s_info:
kind: Volume
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
name: "{{ item.pv_name }}"
register: volume_crd_check
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 3 | Create Longhorn Volume CRD"
kubernetes.core.k8s:
state: present
definition:
apiVersion: longhorn.io/v1beta2
kind: Volume
metadata:
name: "{{ item.item.pv_name }}"
namespace: "{{ longhorn_namespace }}"
spec:
accessMode: "{{ item.item.access_mode | lower | replace('readwriteonce', 'rwo') | replace('readwritemany', 'rwx') }}"
dataEngine: v1
frontend: blockdev
numberOfReplicas: 3
size: "{{ item.item.size_bytes }}"
delegate_to: localhost
loop: "{{ volume_crd_check.results }}"
loop_control:
label: "{{ item.item.pv_name }}"
when: item.resources | default([]) | length == 0
- name: "Phase 3 | Wait for Longhorn replicas to appear (stopped state)"
kubernetes.core.k8s_info:
kind: Replica
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
label_selectors:
- "longhornvolume={{ item.pv_name }}"
register: replicas_check
until: replicas_check.resources | default([]) | length >= 1
retries: 24
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 3 | Wait for Volume status to be populated (webhook cache)"
kubernetes.core.k8s_info:
kind: Volume
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
name: "{{ item.pv_name }}"
register: vol_ready
until: >
(vol_ready.resources | default([]) | first | default({}) ).status.state | default('') != ''
retries: 24
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
# =========================================================================
# PHASE 4 — SCALE DOWN WORKLOADS
# =========================================================================
- name: "Phase 4 | Scale down Deployments"
kubernetes.core.k8s_scale:
kind: Deployment
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 0
wait: yes
wait_timeout: 60
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'Deployment' and item.workload_name != ''
ignore_errors: yes
- name: "Phase 4 | Scale down StatefulSets"
kubernetes.core.k8s_scale:
kind: StatefulSet
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 0
wait: yes
wait_timeout: 60
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'StatefulSet' and item.workload_name != ''
ignore_errors: yes
- name: "Phase 4 | Delete any lingering Error-state pods that may hold volume attachments"
ansible.builtin.shell: |
kubectl get pods -n {{ item.namespace }} \
--field-selector='status.phase=Failed' -o name | xargs -r kubectl delete -n {{ item.namespace }}
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}"
changed_when: false
ignore_errors: yes
# =========================================================================
# PHASE 5 — ATTACH VOLUME VIA MAINTENANCE TICKET
# =========================================================================
- name: "Phase 5 | Create VolumeAttachment maintenance ticket"
kubernetes.core.k8s:
state: present
definition:
apiVersion: longhorn.io/v1beta2
kind: VolumeAttachment
metadata:
name: "{{ item.pv_name }}"
namespace: "{{ longhorn_namespace }}"
spec:
attachmentTickets:
recovery:
generation: 0
id: recovery
nodeID: "{{ item.source_node }}"
parameters:
disableFrontend: "false"
type: longhorn-api
volume: "{{ item.pv_name }}"
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }} → {{ item.source_node }}"
- name: "Phase 5 | Wait for volume to reach attached state"
kubernetes.core.k8s_info:
kind: Volume
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
name: "{{ item.pv_name }}"
register: vol_state
until: >
(vol_state.resources | default([]) | first | default({}) ).status.state | default('') == 'attached'
retries: 24
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 5 | Verify block device exists on target node"
ansible.builtin.stat:
path: "/dev/longhorn/{{ item.pv_name }}"
register: blockdev_check
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 5 | Fail if block device not present"
ansible.builtin.fail:
msg: >
Block device /dev/longhorn/{{ item.item.pv_name }} not found on
{{ item.item.source_node }} after volume attached — check Longhorn logs.
loop: "{{ blockdev_check.results }}"
loop_control:
label: "{{ item.item.pv_name }}"
when: not item.stat.exists
# =========================================================================
# PHASE 6 — INJECT DATA INTO LIVE BLOCK DEVICE
# =========================================================================
- name: "Phase 6 | Inject data via block device (mount, rsync, umount)"
ansible.builtin.shell: |
LIVE="{{ live_mount }}-{{ item.pvc_name }}"
SRC="{{ recovery_mount }}-{{ item.pvc_name }}"
BLOCKDEV="/dev/longhorn/{{ item.pv_name }}"
MERGED="{{ merged_base }}/{{ item.pvc_name }}.img"
# Always unmount on exit (success or partial failure)
cleanup() {
mountpoint -q "$SRC" && umount "$SRC" || true
mountpoint -q "$LIVE" && umount "$LIVE" || true
}
trap cleanup EXIT
mkdir -p "$LIVE" "$SRC"
# Format if not already formatted (idempotent — safe on re-run)
if ! blkid "$BLOCKDEV" | grep -q 'TYPE='; then
mkfs.ext4 -F "$BLOCKDEV"
fi
# Mount live block device if not already mounted
if ! mountpoint -q "$LIVE"; then
mount "$BLOCKDEV" "$LIVE"
fi
# Mount merged recovery image read-only if not already mounted
if ! mountpoint -q "$SRC"; then
mount -o loop,ro,noload "$MERGED" "$SRC"
fi
# Sync data — exclude lost+found
# --ignore-errors: continue past unreadable files (e.g. corrupted parts from power cut)
# rc=23 (partial transfer) is treated as success — bulk data transferred
rsync -av --ignore-errors --exclude='lost+found' "$SRC/" "$LIVE/" || \
{ RC=$?; [ $RC -eq 23 ] && echo "WARNING: rsync rc=23 (some files unreadable in source — expected for power-cut partitions)" || exit $RC; }
delegate_to: "{{ item.source_node }}"
become: yes
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pvc_name }}"
register: inject_output
changed_when: true
- name: "Phase 6 | Show rsync output"
ansible.builtin.debug:
msg: "{{ item.stdout_lines | default([]) }}"
loop: "{{ inject_output.results }}"
loop_control:
label: "{{ item.item.pvc_name }}"
# =========================================================================
# PHASE 7 — DETACH VOLUME
# =========================================================================
- name: "Phase 7 | Remove recovery attachment ticket"
kubernetes.core.k8s_json_patch:
kind: VolumeAttachment
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
name: "{{ item.pv_name }}"
patch:
- op: remove
path: /spec/attachmentTickets/recovery
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
ignore_errors: yes
- name: "Phase 7 | Wait for recovery ticket to be gone"
kubernetes.core.k8s_info:
kind: VolumeAttachment
api_version: longhorn.io/v1beta2
namespace: "{{ longhorn_namespace }}"
name: "{{ item.pv_name }}"
register: va_state
until: >
(va_state.resources | default([]) | first | default({}) ).spec.attachmentTickets.recovery is not defined
retries: 24
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
# =========================================================================
# PHASE 8 — RESTORE PV AND PVC
# =========================================================================
- name: "Phase 8 | Create PersistentVolume (Retain, no claimRef)"
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: PersistentVolume
metadata:
name: "{{ item.pv_name }}"
annotations:
pv.kubernetes.io/provisioned-by: driver.longhorn.io
spec:
accessModes:
- "{{ item.access_mode }}"
capacity:
storage: "{{ item.size_human }}"
csi:
driver: driver.longhorn.io
fsType: ext4
volumeHandle: "{{ item.pv_name }}"
volumeAttributes:
dataEngine: v1
dataLocality: disabled
disableRevisionCounter: "true"
numberOfReplicas: "3"
staleReplicaTimeout: "30"
persistentVolumeReclaimPolicy: Retain
storageClassName: longhorn
volumeMode: Filesystem
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 8 | Wait for PV to be Available or Bound"
kubernetes.core.k8s_info:
kind: PersistentVolume
name: "{{ item.pv_name }}"
register: pv_state
until: >
(pv_state.resources | default([]) | first | default({}) ).status.phase | default('')
in ['Available', 'Bound']
retries: 12
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.pv_name }}"
- name: "Phase 8 | Check if PVC already bound to correct PV"
ansible.builtin.shell: >
kubectl get pvc {{ item.pvc_name }} -n {{ item.namespace }}
-o jsonpath='{.spec.volumeName}' 2>/dev/null || true
register: pvc_current_volume
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.pvc_name }}"
changed_when: false
- name: "Phase 8 | Create PersistentVolumeClaim pinned to PV"
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "{{ item.item.pvc_name }}"
namespace: "{{ item.item.namespace }}"
spec:
accessModes:
- "{{ item.item.access_mode }}"
resources:
requests:
storage: "{{ item.item.size_human }}"
storageClassName: longhorn
volumeMode: Filesystem
volumeName: "{{ item.item.pv_name }}"
delegate_to: localhost
loop: "{{ pvc_current_volume.results }}"
loop_control:
label: "{{ item.item.namespace }}/{{ item.item.pvc_name }}"
when: item.stdout != item.item.pv_name
- name: "Phase 8 | Wait for PVC to be Bound"
kubernetes.core.k8s_info:
kind: PersistentVolumeClaim
namespace: "{{ item.namespace }}"
name: "{{ item.pvc_name }}"
register: pvc_state
until: >
(pvc_state.resources | default([]) | first | default({}) ).status.phase | default('') == 'Bound'
retries: 12
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.pvc_name }}"
# =========================================================================
# PHASE 9 — SCALE UP AND VERIFY
# =========================================================================
- name: "Phase 9 | Scale up Deployments"
kubernetes.core.k8s_scale:
kind: Deployment
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 1
wait: yes
wait_timeout: 120
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'Deployment' and item.workload_name != ''
ignore_errors: yes
- name: "Phase 9 | Scale up StatefulSets"
kubernetes.core.k8s_scale:
kind: StatefulSet
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
replicas: 1
wait: yes
wait_timeout: 120
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_kind == 'StatefulSet' and item.workload_name != ''
ignore_errors: yes
- name: "Phase 9 | Wait for workload to report ready replicas"
kubernetes.core.k8s_info:
kind: "{{ item.workload_kind }}"
name: "{{ item.workload_name }}"
namespace: "{{ item.namespace }}"
register: workload_state
until: >
(workload_state.resources | default([]) | first | default({}) ).status.readyReplicas | default(0) | int >= 1
retries: 24
delay: 5
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.workload_name != ''
ignore_errors: yes
- name: "Phase 9 | Run optional verification command in pod"
ansible.builtin.shell: >
kubectl exec -n {{ item.namespace }}
$(kubectl get pod -n {{ item.namespace }}
-l statefulset.kubernetes.io/pod-name={{ item.workload_name }}-0
--no-headers -o custom-columns=':metadata.name' 2>/dev/null ||
kubectl get pod -n {{ item.namespace }} {{ item.workload_name }}-0
--no-headers -o custom-columns=':metadata.name' 2>/dev/null)
-- sh -c '{{ item.verify_cmd }}'
delegate_to: localhost
loop: "{{ _volumes }}"
loop_control:
label: "{{ item.namespace }}/{{ item.workload_name }}"
when: item.verify_cmd | default('') != ''
register: verify_output
changed_when: false
ignore_errors: yes
- name: "Phase 9 | Show verification output"
ansible.builtin.debug:
msg: "{{ item.stdout_lines | default([]) }}"
loop: "{{ verify_output.results | default([]) }}"
loop_control:
label: "{{ item.item.pvc_name | default('') }}"
when: item.stdout_lines is defined and item.item.verify_cmd | default('') != ''
# =========================================================================
# RECOVERY SUMMARY
# =========================================================================
- name: "Summary | Recovery complete"
ansible.builtin.debug:
msg: |
╔══════════════════════════════════════════════════════╗
║ Longhorn Block-Device Recovery Complete ║
╚══════════════════════════════════════════════════════╝
Volumes recovered:
{% for v in _volumes %}
• {{ v.pvc_name }} ({{ v.namespace }}) ← {{ v.source_node }}:{{ v.source_dir }}
{% endfor %}
Backups retained at: {{ backup_base }}/<pvc-name>/
Merged images at: {{ merged_base }}/<pvc-name>.img
Next steps:
1. Verify application data through the app UI / API
2. Repeat for remaining volumes (update vars file)
3. Run a fresh k8s_pvc backup once all volumes are healthy
@@ -0,0 +1,84 @@
---
# Example vars file for playbooks/recover/longhorn_data.yml
#
# Usage:
# ansible-playbook -i inventory/hosts.yml playbooks/recover/longhorn_data.yml \
# -e @playbooks/recover/longhorn_data_vars.example.yml
#
# HOW TO FILL THIS IN:
#
# 1. Find untouched replica dirs across all nodes:
# for node in pi1 pi2 pi3; do
# echo "=== $node ==="
# ssh $node "sudo du -sh /mnt/arcodange/longhorn/replicas/pvc-<VOLUME>-* 2>/dev/null"
# done
# Pick the dir with the largest size (>16K) and oldest timestamps (from before the incident).
#
# 2. Get pv_name and pvc_name from PV/PVC backup:
# cat /home/pi/arcodange/backups/k3s_pvc/backup_*.volumes | grep -A5 "kind: PersistentVolume"
#
# 3. Get size_bytes from Longhorn volume spec or from:
# cat /mnt/arcodange/longhorn/replicas/<source_dir>/volume.meta
#
# 4. source_node = the node where the untouched dir lives
# source_dir = the exact directory name (e.g. pvc-abc123-998f49ff)
#
# Fields:
# pv_name — Longhorn volume name, equals the PV name (pvc-<uuid>) [REQUIRED]
# pvc_name — PVC name in the namespace [REQUIRED]
# namespace — namespace where the PVC lives [REQUIRED]
# size_bytes — volume capacity in bytes as a string (from volume spec) [REQUIRED]
# size_human — human-readable size for PVC spec (e.g. 128Mi, 8Gi) [REQUIRED]
# access_mode — ReadWriteOnce or ReadWriteMany [REQUIRED]
# workload_kind — Deployment or StatefulSet [REQUIRED]
# workload_name — name of the workload to scale down/up [REQUIRED]
# source_node — node holding the untouched replica dir (pi1/pi2/pi3) [OPTIONAL — auto-discovered]
# source_dir — exact replica dir name on source_node [OPTIONAL — auto-discovered]
# verify_cmd — shell command to run inside pod to confirm data after restore [OPTIONAL]
#
# source_node and source_dir are auto-discovered by Phase 0 (largest dir >16K across all
# nodes). Override them manually only if you want to force a specific replica dir.
longhorn_recovery_volumes:
# --- url-shortener (example, already recovered 2026-04-14) ---
- pv_name: pvc-cdd434d1-c8b4-4a75-acde-2978ec9febd4
pvc_name: url-shortener-data
namespace: url-shortener
size_bytes: "134217728"
size_human: 128Mi
access_mode: ReadWriteOnce
workload_kind: Deployment
workload_name: url-shortener
source_node: pi3
source_dir: pvc-cdd434d1-c8b4-4a75-acde-2978ec9febd4-998f49ff
verify_cmd: "sqlite3 /data/urls.db 'SELECT COUNT(*) FROM urls;'"
# --- traefik (example, already recovered 2026-04-14) ---
# - pv_name: pvc-<traefik-uuid>
# pvc_name: traefik-data
# namespace: traefik
# size_bytes: "134217728"
# size_human: 128Mi
# access_mode: ReadWriteOnce
# workload_kind: Deployment
# workload_name: traefik
# source_node: pi3
# source_dir: pvc-<traefik-uuid>-<hex>
# verify_cmd: ""
# --- vault (uncomment and fill for recovery) ---
# - pv_name: pvc-<vault-uuid>
# pvc_name: vault-data
# namespace: vault
# size_bytes: "1073741824"
# size_human: 1Gi
# access_mode: ReadWriteOnce
# workload_kind: StatefulSet
# workload_name: vault
# source_node: pi2
# source_dir: pvc-<vault-uuid>-<hex>
# verify_cmd: ""
# Add more volumes here following the same pattern.
# Process one at a time first to validate, then batch.
@@ -0,0 +1,17 @@
---
# Recovery vars for Clickhouse
# Source: pi3, dir pvc-1251909b-...-1163420b (2.6G — largest, snapshot verified non-zero)
# Generated: 2026-04-14
longhorn_recovery_volumes:
- pv_name: pvc-1251909b-3cef-40c6-881c-3bb6e929a596
pvc_name: clickhouse-storage-clickhouse-0
namespace: tools
size_bytes: "17179869184" # 16Gi
size_human: 16Gi
access_mode: ReadWriteOnce
workload_kind: StatefulSet
workload_name: clickhouse
source_node: pi3
source_dir: pvc-1251909b-3cef-40c6-881c-3bb6e929a596-1163420b
verify_cmd: "clickhouse-client --query 'SHOW DATABASES'"
@@ -0,0 +1,38 @@
---
# Recovery vars for erp and hashicorp-vault volumes
# source_node/source_dir omitted — auto-discovered by Phase 0
longhorn_recovery_volumes:
- pv_name: pvc-7971918e-e47f-4739-a976-965ea2d770b4
pvc_name: erp
namespace: erp
size_bytes: "53687091200"
size_human: 50Gi
access_mode: ReadWriteMany
workload_kind: Deployment
workload_name: "" # intentionally blank — ERP needs Vault unsealed first; scale up manually
verify_cmd: ""
# hashicorp-vault StatefulSet has two PVCs (audit + data).
# workload_name is set only on the last entry so the StatefulSet is scaled up
# once after both volumes are ready, not between them.
- pv_name: pvc-6d2ea1c7-9327-4992-a02c-93ae604eda70
pvc_name: audit-hashicorp-vault-0
namespace: tools
size_bytes: "10737418240"
size_human: 10Gi
access_mode: ReadWriteOnce
workload_kind: StatefulSet
workload_name: ""
verify_cmd: ""
- pv_name: pvc-ca5567d3-a682-4cee-8ff1-2b8e23260635
pvc_name: data-hashicorp-vault-0
namespace: tools
size_bytes: "10737418240"
size_human: 10Gi
access_mode: ReadWriteOnce
workload_kind: StatefulSet
workload_name: hashicorp-vault
verify_cmd: ""
@@ -0,0 +1,47 @@
---
# Recovery vars for remaining volumes (prometheus, alertmanager, redis, backups-rwx)
# source_node and source_dir intentionally omitted — auto-discovered by Phase 0
longhorn_recovery_volumes:
- pv_name: pvc-88e18c7f-2cfd-45e3-be5b-78c31ab829e9
pvc_name: prometheus-server
namespace: tools
size_bytes: "8589934592"
size_human: 8Gi
access_mode: ReadWriteOnce
workload_kind: Deployment
workload_name: prometheus-server
source_node: pi2
source_dir: pvc-88e18c7f-2cfd-45e3-be5b-78c31ab829e9-910583f6
verify_cmd: ""
- pv_name: pvc-aed7f2c4-1948-487a-8d10-d8a1372289b4
pvc_name: storage-prometheus-alertmanager-0
namespace: tools
size_bytes: "2147483648"
size_human: 2Gi
access_mode: ReadWriteOnce
workload_kind: StatefulSet
workload_name: prometheus-alertmanager
verify_cmd: ""
- pv_name: pvc-d1d5482b-81c8-4d7c-a528-7a57ef47a5ce
pvc_name: redis-storage-redis-0
namespace: tools
size_bytes: "1073741824"
size_human: 1Gi
access_mode: ReadWriteOnce
workload_kind: StatefulSet
workload_name: redis
verify_cmd: "redis-cli ping"
- pv_name: pvc-efda1d2f-1db8-46dd-9a97-3d11f1807ffa
pvc_name: backups-rwx
namespace: longhorn-system
size_bytes: "53687091200"
size_human: 50Gi
access_mode: ReadWriteMany
workload_kind: Deployment
workload_name: ""
verify_cmd: ""