Files
CloudOps/ansible/dr-bootstrap.yml
root 5802b200a7 Unify restore + DR bootstrap: every remote restore now goes through Ansible
Previously restore_start()'s 'remote' target did its own manual scp+ssh+
restore-k8s-apps.sh dance, entirely separate from the new DR bootstrap
playbook (ansible/dr-bootstrap.yml) — meaning restoring onto a genuinely
empty server would just fail (no namespace/PVC/Deployments, and
restore-k8s-apps.sh assumes those exist). Every "External Machine" restore
now runs the same playbook instead: it's safe for both cases, not just the
empty-server one — create-namespace-if-missing and `kubectl apply` for the
sanitized PVC/Secret/manifests are no-ops against a target that already
has this app running with a matching spec (apply only reconciles
differences, and a backup's own captured manifests are by definition
identical to what's already live) — so "restore onto an existing cluster"
and "restore onto nothing" are the same command now; the playbook's own
checks-then-acts steps decide how much of it actually needs to do
anything.

Three real bugs found getting this working, not just wiring it up blind:

1. dr-bootstrap.yml's `hosts: dr_target` only matches a named inventory
   group — the dynamic single-host inventory app.py builds per-request
   (`-i '<ip>,'`) doesn't create one, so nothing matched and the play
   silently skipped. Changed to `hosts: all`, which both invocation styles
   satisfy.

2. ansible-playbook is a pip console-script installed next to whichever
   python is running — the main pod's system python (no venv there) or
   this server's own venv/bin on the standby. A bare "ansible-playbook" in
   the shelled-out command only resolves on the main pod; the standby's
   venv/bin is never on PATH when its python is invoked directly rather
   than through `activate`, so it'd fail there. Resolved relative to
   sys.executable instead, which is correct in both.

3. Passing connection options via `-e ansible_ssh_common_args='-o
   StrictHostKeyChecking=no ...'` hit a real bug in this ansible-core
   version's SSH connection plugin: its own internal tty-detection
   re-parses that string with a strict argparse and throws "argument -o:
   expected one argument" even for one well-formed -o KEY=VALUE — verified
   directly on the CLI, not a shell-quoting artifact from this code.
   Switched to ANSIBLE_HOST_KEY_CHECKING=False / ANSIBLE_TIMEOUT=15 env
   vars, ansible's own dedicated mechanism for the same effect, which
   bypasses that code path entirely.

Also added ansible-core to requirements.txt (installed automatically by
sync-standby-platform.sh's existing `pip install -r requirements.txt`
step; needs a Jenkins rebuild to reach the main pod's image), and synced
ansible/ to the standby the same way backup/ already was — restore_start()
references dr-bootstrap.yml as a fixed absolute path, and that directory
didn't exist there at all before this.

Verified for real end-to-end: triggered a restore via the standby's
actual web UI (target=remote, localhost:2224 tunnel) — Ansible ran env
checks, found k3s already present, reconciled the namespace/PVC/Secret/
manifests (all no-ops against the live cluster), then restore-k8s-apps.sh
restored the data. n8n on the real main server came back healthy
(healthz ok) with all 11 workflows intact in the restored Postgres DB.
2026-08-21 15:03:12 +02:00

180 lines
7.3 KiB
YAML

---
# dr-bootstrap.yml — minimum-viable cross-server disaster recovery.
#
# Provisions a target server into a working k3s node (checking the
# environment first, installing only if missing — safe to re-run) and
# restores one app from a captured backup-k8s-apps.sh archive onto it,
# from scratch: creates the namespace, the PVC(s) (never captured by the
# backup before this — see sanitize_k8s_manifest.py's header) and the
# Deployments/Services/Secret, then hands off to the existing
# restore-k8s-apps.sh for the actual data population (PVC contents + DB),
# which already assumes namespace/PVC/Deployments exist — this playbook is
# what makes that assumption true on a target that starts with nothing.
#
# Usage:
# ansible-playbook -i inventory.ini dr-bootstrap.yml \
# -e dr_app=n8n -e dr_backup_archive=/root/backups/myapps-k8s-backup-XXXXXXXX_XXXXXX.tar.gz
#
# NOT in scope (same cut as restore-k8s-apps.sh's own header): this
# targets a server that already has network/DNS/SSH access sorted out —
# it provisions the CLUSTER and the APP, not the surrounding
# infrastructure.
- name: DR Bootstrap
hosts: all
gather_facts: yes
vars:
remote_work_dir: "/root/dr-bootstrap-{{ dr_app }}"
k3s_version: "v1.35.4+k3s1"
tasks:
- name: Fail fast if required vars are missing
ansible.builtin.assert:
that:
- dr_app is defined
- dr_backup_archive is defined
fail_msg: "Pass -e dr_app=<name> -e dr_backup_archive=<local path to a myapps-k8s-backup-*.tar.gz>"
# ── 1. Environment check — before touching anything ──────────────
- name: Check free disk space and memory
ansible.builtin.set_fact:
disk_free_gb: "{{ ((ansible_facts['mounts'] | selectattr('mount', 'equalto', '/') | first).size_available / (1024**3)) | round(1) }}"
- name: Assert environment meets k3s minimums
ansible.builtin.assert:
that:
- disk_free_gb | float > 5
- ansible_facts['memtotal_mb'] > 1500
fail_msg: >
Environment below k3s minimums on {{ inventory_hostname }}:
{{ disk_free_gb }}GB free disk / {{ ansible_facts['memtotal_mb'] }}MB RAM
(need >5GB / >1.5GB). Not proceeding.
success_msg: >
Environment OK on {{ inventory_hostname }}: {{ disk_free_gb }}GB free disk,
{{ ansible_facts['memtotal_mb'] }}MB RAM, {{ ansible_facts['processor_vcpus'] }} vCPUs.
# ── 2. k3s — install only if missing, never re-run blindly ───────
- name: Check if k3s is already installed
ansible.builtin.command: which k3s
register: k3s_check
ignore_errors: true
changed_when: false
- name: Install k3s (skipped if already present)
ansible.builtin.shell: >
curl -sfL https://get.k3s.io |
INSTALL_K3S_VERSION="{{ k3s_version }}" sh -s - --write-kubeconfig-mode 644
when: k3s_check.rc != 0
- name: Wait for the k3s node to report Ready
ansible.builtin.shell: k3s kubectl get nodes --no-headers | awk '{print $2}'
register: node_status
until: "'Ready' in node_status.stdout"
retries: 24
delay: 5
changed_when: false
# ── 3. Stage the backup + tooling on the target ───────────────────
- name: Create working directory
ansible.builtin.file:
path: "{{ remote_work_dir }}"
state: directory
mode: '0755'
- name: Copy backup archive to target
ansible.builtin.copy:
src: "{{ dr_backup_archive }}"
dest: "{{ remote_work_dir }}/backup.tar.gz"
- name: Extract backup archive
ansible.builtin.unarchive:
src: "{{ remote_work_dir }}/backup.tar.gz"
dest: "{{ remote_work_dir }}"
remote_src: true
extra_opts: ['--strip-components=1']
- name: Copy sanitizer script
ansible.builtin.copy:
src: sanitize_k8s_manifest.py
dest: "{{ remote_work_dir }}/sanitize_k8s_manifest.py"
mode: '0755'
- name: Copy restore-k8s-apps.sh
ansible.builtin.copy:
src: "{{ playbook_dir }}/../backup/restore-k8s-apps.sh"
dest: "{{ remote_work_dir }}/restore-k8s-apps.sh"
mode: '0755'
- name: Determine the app's namespace from the captured manifests
ansible.builtin.shell: >
python3 -c "import yaml; d=yaml.safe_load(open('{{ remote_work_dir }}/{{ dr_app }}/manifests.yaml'));
print(d['items'][0]['metadata']['namespace'])"
register: ns_result
changed_when: false
- name: Set dr_namespace fact
ansible.builtin.set_fact:
dr_namespace: "{{ ns_result.stdout }}"
# ── 4. Provision namespace + PVC + Secret + Deployments/Services ──
- name: Create namespace (idempotent)
ansible.builtin.shell: >
k3s kubectl get namespace {{ dr_namespace }} ||
k3s kubectl create namespace {{ dr_namespace }}
changed_when: false
- name: Check for a captured PVC spec
ansible.builtin.stat:
path: "{{ remote_work_dir }}/{{ dr_app }}/pvc.yaml"
register: pvc_file
- name: Sanitize + apply PVC spec
when: pvc_file.stat.exists
block:
- ansible.builtin.command: >
python3 {{ remote_work_dir }}/sanitize_k8s_manifest.py
{{ remote_work_dir }}/{{ dr_app }}/pvc.yaml
{{ remote_work_dir }}/{{ dr_app }}/pvc.clean.yaml
- ansible.builtin.command: k3s kubectl apply -f {{ remote_work_dir }}/{{ dr_app }}/pvc.clean.yaml
- name: Sanitize + apply Secret
block:
- ansible.builtin.command: >
python3 {{ remote_work_dir }}/sanitize_k8s_manifest.py
{{ remote_work_dir }}/{{ dr_app }}/secret.yaml
{{ remote_work_dir }}/{{ dr_app }}/secret.clean.yaml
- ansible.builtin.command: k3s kubectl apply -f {{ remote_work_dir }}/{{ dr_app }}/secret.clean.yaml
- name: Sanitize + apply Deployments/Services/Ingress
block:
- ansible.builtin.command: >
python3 {{ remote_work_dir }}/sanitize_k8s_manifest.py
{{ remote_work_dir }}/{{ dr_app }}/manifests.yaml
{{ remote_work_dir }}/{{ dr_app }}/manifests.clean.yaml
- ansible.builtin.command: k3s kubectl apply -f {{ remote_work_dir }}/{{ dr_app }}/manifests.clean.yaml
- name: Wait for PVC(s) to be Bound
when: pvc_file.stat.exists
ansible.builtin.shell: >
k3s kubectl get pvc -n {{ dr_namespace }} -o jsonpath='{.items[*].status.phase}'
register: pvc_status
until: "'Pending' not in pvc_status.stdout"
retries: 12
delay: 5
changed_when: false
# ── 5. Data population — reuse the existing, already-validated
# restore pipeline. It handles PVC contents + DB restore; steps
# 1-4 above are only what's needed to make its own assumption
# (namespace/PVC/Deployments already exist) true on a target
# that started with nothing. ──────────────────────────────────
- name: Run restore-k8s-apps.sh to populate data
ansible.builtin.command: bash restore-k8s-apps.sh --apps {{ dr_app }}
args:
chdir: "{{ remote_work_dir }}"
register: restore_output
- name: Show restore output
ansible.builtin.debug:
var: restore_output.stdout_lines