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.
180 lines
7.3 KiB
YAML
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
|