Compare commits
5 Commits
9361b9c4c1
...
9b631f527c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b631f527c | ||
|
|
5802b200a7 | ||
|
|
7ddce24e62 | ||
|
|
054a9e1fcb | ||
|
|
459c2974f0 |
179
ansible/dr-bootstrap.yml
Normal file
179
ansible/dr-bootstrap.yml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
2
ansible/inventory.ini
Normal file
2
ansible/inventory.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[dr_target]
|
||||||
|
178.18.243.51 ansible_user=root ansible_ssh_private_key_file=/root/.ssh/id_rsa ansible_ssh_common_args='-o StrictHostKeyChecking=no'
|
||||||
60
ansible/sanitize_k8s_manifest.py
Executable file
60
ansible/sanitize_k8s_manifest.py
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""sanitize_k8s_manifest.py — strip source-cluster-specific fields from a
|
||||||
|
captured `kubectl get ... -o yaml` dump so it can be applied fresh onto a
|
||||||
|
different (DR/standby) cluster.
|
||||||
|
|
||||||
|
backup-k8s-apps.sh's manifests.yaml/pvc.yaml/secret.yaml are raw `kubectl
|
||||||
|
get -o yaml` output: server-assigned fields like resourceVersion/uid are
|
||||||
|
always present, and PVCs additionally carry spec.volumeName + a Bound
|
||||||
|
status pointing at a specific PersistentVolume that only exists on the
|
||||||
|
ORIGINAL cluster. Applying any of that verbatim onto a fresh cluster either
|
||||||
|
fails outright (a stale resourceVersion trips optimistic-concurrency
|
||||||
|
checks — the exact bug fixed in restore-k8s-apps.sh's Secret handling) or
|
||||||
|
silently tries to bind a PVC to a PV that was never created there.
|
||||||
|
|
||||||
|
Usage: sanitize_k8s_manifest.py <in.yaml> <out.yaml>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DROP_METADATA_KEYS = (
|
||||||
|
'resourceVersion', 'uid', 'creationTimestamp', 'selfLink',
|
||||||
|
'generation', 'finalizers', 'managedFields', 'annotations',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_item(item):
|
||||||
|
meta = item.get('metadata') or {}
|
||||||
|
for key in DROP_METADATA_KEYS:
|
||||||
|
meta.pop(key, None)
|
||||||
|
|
||||||
|
item.pop('status', None)
|
||||||
|
|
||||||
|
if item.get('kind') == 'PersistentVolumeClaim':
|
||||||
|
spec = item.get('spec') or {}
|
||||||
|
spec.pop('volumeName', None)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(f"usage: {sys.argv[0]} <in.yaml> <out.yaml>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
|
doc = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if doc is None:
|
||||||
|
items = []
|
||||||
|
elif doc.get('kind') == 'List':
|
||||||
|
items = [sanitize_item(i) for i in doc.get('items', [])]
|
||||||
|
else:
|
||||||
|
items = [sanitize_item(doc)]
|
||||||
|
|
||||||
|
with open(sys.argv[2], 'w') as f:
|
||||||
|
yaml.safe_dump_all(items, f, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -306,6 +306,19 @@ for app in $ALL_APPS; do
|
|||||||
&& { echo "✅"; rm -f "$APP_DIR/manifests.err"; } \
|
&& { echo "✅"; rm -f "$APP_DIR/manifests.err"; } \
|
||||||
|| { echo "⚠️ FAILED (see manifests.err)"; ERRORS+=("$app:manifests:$(tr '\n' ' ' < "$APP_DIR/manifests.err" | cut -c1-200)"); }
|
|| { echo "⚠️ FAILED (see manifests.err)"; ERRORS+=("$app:manifests:$(tr '\n' ' ' < "$APP_DIR/manifests.err" | cut -c1-200)"); }
|
||||||
|
|
||||||
|
# ---- 1b. PVC specs — NOT covered by the manifests capture above, and
|
||||||
|
# needed to provision a namespace from scratch on a fresh/DR
|
||||||
|
# cluster (restore-k8s-apps.sh assumes the PVC already exists;
|
||||||
|
# this is what actually creates it there). Same label selector
|
||||||
|
# as the manifests capture, so this stays in lockstep with
|
||||||
|
# whatever PVCs are actually labeled as ours — no separate
|
||||||
|
# per-app name table to keep in sync. ----
|
||||||
|
echo -n " 💾 PVC specs ... "
|
||||||
|
kubectl get pvc -n "$ns" -l owner=ameni-boukattaya -o yaml \
|
||||||
|
> "$APP_DIR/pvc.yaml" 2>"$APP_DIR/pvc.err" \
|
||||||
|
&& { echo "✅"; rm -f "$APP_DIR/pvc.err"; } \
|
||||||
|
|| { echo "⚠️ FAILED (see pvc.err)"; ERRORS+=("$app:pvc_spec:$(tr '\n' ' ' < "$APP_DIR/pvc.err" | cut -c1-200)"); }
|
||||||
|
|
||||||
# ---- 2. Secret (plaintext, encryption-at-rest deferred) ----
|
# ---- 2. Secret (plaintext, encryption-at-rest deferred) ----
|
||||||
echo -n " 🔑 Secret ($secret_name) ... "
|
echo -n " 🔑 Secret ($secret_name) ... "
|
||||||
kubectl get secret "$secret_name" -n "$ns" -o yaml \
|
kubectl get secret "$secret_name" -n "$ns" -o yaml \
|
||||||
@@ -482,6 +495,16 @@ if [ $VM_SCP_RC -eq 0 ]; then
|
|||||||
"${BACKUP_NAME}.tar.gz.sha256" \
|
"${BACKUP_NAME}.tar.gz.sha256" \
|
||||||
"${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
"${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
||||||
TIER2_STATUS="ok"
|
TIER2_STATUS="ok"
|
||||||
|
|
||||||
|
# VM-side retention — this tier never had any pruning at all until
|
||||||
|
# found accumulating 85 legacy pre-k8s archives (131GB) untouched
|
||||||
|
# since May. Mirrors the local [TIER 1] policy above: same
|
||||||
|
# MAX_BACKUPS, own glob/lineage only (never touches other
|
||||||
|
# unrelated backup dirs also living on this VM).
|
||||||
|
ssh -i "$VM_KEY" -p "$VM_PORT" -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||||
|
"${VM_USER}@${VM_HOST}" \
|
||||||
|
"cd ${VM_DEST} && ls -t myapps-k8s-backup-*.tar.gz 2>/dev/null | tail -n +$((MAX_BACKUPS + 1)) | xargs -r -I{} rm -f {} {}.sha256 {}.meta.json" \
|
||||||
|
&>/dev/null || true
|
||||||
else
|
else
|
||||||
echo " ⚠️ VM transfer failed (rc=$VM_SCP_RC) — local backup is safe, continuing..."
|
echo " ⚠️ VM transfer failed (rc=$VM_SCP_RC) — local backup is safe, continuing..."
|
||||||
TIER2_STATUS="failed:rc=${VM_SCP_RC}"
|
TIER2_STATUS="failed:rc=${VM_SCP_RC}"
|
||||||
|
|||||||
100
platform/app.py
100
platform/app.py
@@ -3,6 +3,7 @@ from flask import Flask, render_template, request, redirect, url_for, session, j
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
@@ -69,11 +70,11 @@ def _stream_restore(job_id, cmd):
|
|||||||
restore_jobs[job_id]['status'] = 'error'
|
restore_jobs[job_id]['status'] = 'error'
|
||||||
|
|
||||||
|
|
||||||
def _stream_backup(job_id, script_path):
|
def _stream_backup(job_id, script_path, extra_args=None):
|
||||||
backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()}
|
backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()}
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
['bash', script_path],
|
['bash', script_path] + (extra_args or []),
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
text=True, bufsize=1
|
text=True, bufsize=1
|
||||||
)
|
)
|
||||||
@@ -441,8 +442,21 @@ def api_backup_run():
|
|||||||
'message': f'Backup script not found at {script}'
|
'message': f'Backup script not found at {script}'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
# Optional {"apps": [...]} body — omitted, empty, or "all apps checked"
|
||||||
|
# all mean the same thing as before this existed: no --apps flag, every
|
||||||
|
# app bundled into one archive together (the script's own default).
|
||||||
|
# Only becomes --apps a,b when the UI's app-picker has SOME apps
|
||||||
|
# unchecked, for a deliberate one-off single/partial-app backup.
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_apps = data.get('apps', [])
|
||||||
|
selected = sorted({
|
||||||
|
a.strip().lower() for a in raw_apps
|
||||||
|
if isinstance(a, str) and a.strip().lower() in ALLOWED_RESTORE_APPS
|
||||||
|
})
|
||||||
|
extra_args = ['--apps', ','.join(selected)] if selected and len(selected) < len(ALLOWED_RESTORE_APPS) else []
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
t = threading.Thread(target=_stream_backup, args=(job_id, script), daemon=True)
|
t = threading.Thread(target=_stream_backup, args=(job_id, script, extra_args), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
|
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
|
||||||
|
|
||||||
@@ -869,36 +883,74 @@ def restore_start():
|
|||||||
if not remote_ip:
|
if not remote_ip:
|
||||||
return jsonify({'error': 'remote_ip required'}), 400
|
return jsonify({'error': 'remote_ip required'}), 400
|
||||||
|
|
||||||
base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
# Unified path: every "External Machine" restore goes through the
|
||||||
|
# same Ansible playbook used for from-scratch DR (ansible/dr-
|
||||||
|
# bootstrap.yml) rather than a bare scp+ssh+restore-k8s-apps.sh
|
||||||
|
# dance. It's safe for BOTH cases, not just the empty-server one:
|
||||||
|
# the playbook's own steps (create-namespace-if-missing, `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.
|
||||||
|
ansible_dir = '/root/CloudOps/ansible'
|
||||||
|
playbook_path = f'{ansible_dir}/dr-bootstrap.yml'
|
||||||
|
if not os.path.exists(playbook_path):
|
||||||
|
return jsonify({'error': f'Ansible playbook not found at {playbook_path}'}), 500
|
||||||
|
|
||||||
|
# ANSIBLE_HOST_KEY_CHECKING=False (env var, below) rather than
|
||||||
|
# -e ansible_ssh_common_args='-o StrictHostKeyChecking=no ...':
|
||||||
|
# passing that as an extra-var hits what looks like a real bug in
|
||||||
|
# this ansible-core version's SSH connection plugin (its own
|
||||||
|
# internal tty-detection re-parses the string with a strict
|
||||||
|
# argparse and throws "argument -o: expected one argument" even
|
||||||
|
# for a single well-formed -o KEY=VALUE) — reproduced directly on
|
||||||
|
# the CLI, not a quoting artifact from this code. The env var
|
||||||
|
# achieves the same effect (disables host-key checking) through
|
||||||
|
# ansible's own dedicated option instead, bypassing that path
|
||||||
|
# entirely.
|
||||||
|
common_evars = [
|
||||||
|
f"ansible_user={remote_user}",
|
||||||
|
f"ansible_port={remote_port}",
|
||||||
|
]
|
||||||
if auth_method == 'key':
|
if auth_method == 'key':
|
||||||
if not ssh_key_path:
|
if not ssh_key_path:
|
||||||
return jsonify({'error': 'ssh_key_path required'}), 400
|
return jsonify({'error': 'ssh_key_path required'}), 400
|
||||||
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_opts}"
|
common_evars.append(f"ansible_ssh_private_key_file={ssh_key_path}")
|
||||||
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_opts}"
|
|
||||||
else:
|
else:
|
||||||
if not ssh_password:
|
if not ssh_password:
|
||||||
return jsonify({'error': 'ssh_password required'}), 400
|
return jsonify({'error': 'ssh_password required'}), 400
|
||||||
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_opts}"
|
common_evars.append(f"ansible_password='{ssh_password}'")
|
||||||
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_opts}"
|
|
||||||
|
|
||||||
remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}"
|
evars_str = " ".join(f"-e {v}" for v in common_evars)
|
||||||
|
# ansible-playbook is a pip console-script installed alongside
|
||||||
|
# whichever python is actually running this process — the system
|
||||||
|
# one in the main pod's Docker image (no venv there), or this
|
||||||
|
# server's own venv/bin on the standby. A bare "ansible-playbook"
|
||||||
|
# in the command string only resolves on the main pod (system PATH
|
||||||
|
# already includes it); on the standby, running a venv's python
|
||||||
|
# directly (not through `activate`) never puts venv/bin on PATH,
|
||||||
|
# so it'd fail with "command not found" there. Resolving it
|
||||||
|
# relative to sys.executable works in both.
|
||||||
|
ansible_playbook_bin = os.path.join(os.path.dirname(sys.executable), 'ansible-playbook')
|
||||||
|
|
||||||
|
# dr-bootstrap.yml restores one app per run (it reads that app's own
|
||||||
|
# manifests.yaml to work out the namespace) — loop for a multi-app
|
||||||
|
# selection, each run against the same already-staged backup_path.
|
||||||
|
apps_for_ansible = selected_apps if selected_apps else sorted(ALLOWED_RESTORE_APPS)
|
||||||
|
ansible_env = "ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_TIMEOUT=15"
|
||||||
|
per_app_cmds = [
|
||||||
|
f"echo '=== [{a}] provisioning (if needed) + restore via Ansible ===' && "
|
||||||
|
f"{ansible_env} {ansible_playbook_bin} -i '{remote_ip},' {evars_str} "
|
||||||
|
f"-e dr_app={a} -e dr_backup_archive={backup_path} {playbook_path}"
|
||||||
|
for a in apps_for_ansible
|
||||||
|
]
|
||||||
cmd = (
|
cmd = (
|
||||||
f"echo 'Connecting to {remote_user}@{remote_ip}:{remote_port}...' && "
|
f"echo 'Target: {remote_user}@{remote_ip}:{remote_port}' && "
|
||||||
f"echo 'Apps: {apps_label}' && "
|
f"echo 'Apps: {apps_label}' && "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
|
+ " && ".join(per_app_cmds)
|
||||||
f"echo 'Connected.' && "
|
|
||||||
f"echo 'Copying backup archive...' && "
|
|
||||||
f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && "
|
|
||||||
f"echo 'Copying restore script...' && "
|
|
||||||
f"{scp_prefix} {restore_script_local} {remote_user}@{remote_ip}:{remote_dest}/restore-k8s-apps.sh && "
|
|
||||||
f"echo 'Running restore on {remote_ip}:{remote_port}...' && "
|
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} "
|
|
||||||
f"'set -e && cd {remote_dest} && "
|
|
||||||
f"tar -xzf {backup_file} --strip-components=1 && "
|
|
||||||
f"chmod +x restore-k8s-apps.sh && bash restore-k8s-apps.sh{apps_flag}' ; "
|
|
||||||
f"EXIT=$? ; "
|
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
|
|
||||||
f"exit $EXIT"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -241,8 +241,26 @@ def _with_status(names, sidecars):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_local_sidecars():
|
||||||
|
"""Same local-dir-first-else-SSH pattern as _fetch_vm_sidecars() below,
|
||||||
|
but for /root/backups on the main server. _load_local_sidecars() alone
|
||||||
|
has no such fallback — a plain glob.glob() against a hardcoded path —
|
||||||
|
so on the standby (where /root/backups doesn't exist locally at all)
|
||||||
|
it silently returned {}, and every local-column status dot rendered as
|
||||||
|
gray/unknown regardless of the backup's real status."""
|
||||||
|
if os.path.isdir('/root/backups'):
|
||||||
|
return _load_local_sidecars('/root/backups')
|
||||||
|
cmd = (
|
||||||
|
"for f in /root/backups/*.tar.gz.meta.json; do "
|
||||||
|
"[ -f \"$f\" ] && echo \"===META:$(basename \"$f\")===\" && cat \"$f\"; "
|
||||||
|
"done 2>/dev/null"
|
||||||
|
)
|
||||||
|
stdout, _ = _ssh_main(cmd, timeout=25)
|
||||||
|
return _parse_vm_sidecars(stdout)
|
||||||
|
|
||||||
|
|
||||||
def get_local_backups_with_status():
|
def get_local_backups_with_status():
|
||||||
return _with_status(get_local_backups(), _load_local_sidecars())
|
return _with_status(get_local_backups(), _fetch_local_sidecars())
|
||||||
|
|
||||||
|
|
||||||
def get_vm_backups_with_status():
|
def get_vm_backups_with_status():
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ boto3==1.43.5
|
|||||||
flask
|
flask
|
||||||
psutil
|
psutil
|
||||||
kubernetes
|
kubernetes
|
||||||
|
ansible-core
|
||||||
@@ -410,11 +410,15 @@ function renderBackupList(items, id, source) {
|
|||||||
const detailsBtn = typeof showBackupDetails === 'function'
|
const detailsBtn = typeof showBackupDetails === 'function'
|
||||||
? `<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('${source}','${safe}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>`
|
? `<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('${source}','${safe}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>`
|
||||||
: '';
|
: '';
|
||||||
|
const appsLabel = (b.apps && b.apps.length)
|
||||||
|
? `<span class="badge" style="background:rgba(59,130,246,0.12);color:var(--accent2);font-size:10px;white-space:nowrap;">${b.apps.length >= 5 ? 'All apps' : escapeHtml(b.apps.join(', '))}</span>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
||||||
<div class="backup-item-main">
|
<div class="backup-item-main">
|
||||||
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
||||||
<span class="backup-name">${escapeHtml(name)}</span>
|
<span class="backup-name">${escapeHtml(name)}</span>
|
||||||
|
${appsLabel}
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
${detailsBtn}
|
${detailsBtn}
|
||||||
@@ -489,7 +493,14 @@ async function runManualBackup() {
|
|||||||
if (wrapper) wrapper.style.display = '';
|
if (wrapper) wrapper.style.display = '';
|
||||||
if (logEl) logEl.innerHTML = '';
|
if (logEl) logEl.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/backups/run', { method: 'POST' });
|
const selectedApps = Array.from(
|
||||||
|
document.querySelectorAll('#backup-apps-checkbox-group input[type="checkbox"]:checked')
|
||||||
|
).map((cb) => cb.value);
|
||||||
|
const r = await fetch('/api/backups/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apps: selectedApps }),
|
||||||
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.success) throw new Error(d.message || 'Failed');
|
if (!d.success) throw new Error(d.message || 'Failed');
|
||||||
manualBackupJobId = d.job_id;
|
manualBackupJobId = d.job_id;
|
||||||
|
|||||||
@@ -252,7 +252,52 @@
|
|||||||
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:4px 0 12px;">
|
<div style="padding:4px 0 12px;">
|
||||||
<p style="color:var(--text2);font-size:13px;margin-bottom:14px;">Manually trigger a backup of all containers</p>
|
<p style="color:var(--text2);font-size:13px;margin-bottom:10px;">
|
||||||
|
All apps are backed up together into one archive by default. Uncheck any app to leave it out —
|
||||||
|
useful for a quick one-off backup of a single app before a risky change, without waiting on the rest.
|
||||||
|
</p>
|
||||||
|
<div class="app-picker-grid" id="backup-apps-checkbox-group" style="margin-bottom:14px;">
|
||||||
|
<label class="app-picker-card">
|
||||||
|
<input type="checkbox" value="frappe" checked>
|
||||||
|
<div class="app-picker-body">
|
||||||
|
<span class="app-picker-icon">🧾</span>
|
||||||
|
<div><div class="app-picker-name">Frappe / ERPNext</div></div>
|
||||||
|
<span class="app-picker-check"><i class="fas fa-check"></i></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="app-picker-card">
|
||||||
|
<input type="checkbox" value="odoo" checked>
|
||||||
|
<div class="app-picker-body">
|
||||||
|
<span class="app-picker-icon">🟣</span>
|
||||||
|
<div><div class="app-picker-name">Odoo</div></div>
|
||||||
|
<span class="app-picker-check"><i class="fas fa-check"></i></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="app-picker-card">
|
||||||
|
<input type="checkbox" value="nextcloud" checked>
|
||||||
|
<div class="app-picker-body">
|
||||||
|
<span class="app-picker-icon">☁️</span>
|
||||||
|
<div><div class="app-picker-name">Nextcloud</div></div>
|
||||||
|
<span class="app-picker-check"><i class="fas fa-check"></i></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="app-picker-card">
|
||||||
|
<input type="checkbox" value="mautic" checked>
|
||||||
|
<div class="app-picker-body">
|
||||||
|
<span class="app-picker-icon">📣</span>
|
||||||
|
<div><div class="app-picker-name">Mautic</div></div>
|
||||||
|
<span class="app-picker-check"><i class="fas fa-check"></i></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="app-picker-card">
|
||||||
|
<input type="checkbox" value="n8n" checked>
|
||||||
|
<div class="app-picker-body">
|
||||||
|
<span class="app-picker-icon">🔁</span>
|
||||||
|
<div><div class="app-picker-name">n8n</div></div>
|
||||||
|
<span class="app-picker-check"><i class="fas fa-check"></i></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" onclick="runManualBackup()" id="manual-backup-btn">
|
<button class="btn btn-primary" onclick="runManualBackup()" id="manual-backup-btn">
|
||||||
<i class="fas fa-play"></i> Run Backup Now
|
<i class="fas fa-play"></i> Run Backup Now
|
||||||
</button>
|
</button>
|
||||||
@@ -288,6 +333,11 @@
|
|||||||
<div class="backup-item-main">
|
<div class="backup-item-main">
|
||||||
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
|
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
|
||||||
<span class="backup-name">{{ b.name }}</span>
|
<span class="backup-name">{{ b.name }}</span>
|
||||||
|
{% if b.apps %}
|
||||||
|
<span class="badge" style="background:rgba(59,130,246,0.12);color:var(--accent2);font-size:10px;white-space:nowrap;">
|
||||||
|
{% if b.apps|length >= 5 %}All apps{% else %}{{ b.apps|join(', ') }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||||
@@ -313,6 +363,11 @@
|
|||||||
<div class="backup-item-main">
|
<div class="backup-item-main">
|
||||||
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
|
<span class="status-dot status-dot-{{ b.status }}" title="{{ b.status }}{% if b.apps %} · apps: {{ b.apps|join(', ') }}{% endif %}{% if b.size_human %} · {{ b.size_human }}{% endif %}"></span>
|
||||||
<span class="backup-name">{{ b.name }}</span>
|
<span class="backup-name">{{ b.name }}</span>
|
||||||
|
{% if b.apps %}
|
||||||
|
<span class="badge" style="background:rgba(59,130,246,0.12);color:var(--accent2);font-size:10px;white-space:nowrap;">
|
||||||
|
{% if b.apps|length >= 5 %}All apps{% else %}{{ b.apps|join(', ') }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b.name }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ LIVE_CONFIG="/root/management-platform/config.py"
|
|||||||
# here to scp across, so mirror it too, not just platform/.
|
# here to scp across, so mirror it too, not just platform/.
|
||||||
LOCAL_BACKUP_SRC="/root/CloudOps/backup/"
|
LOCAL_BACKUP_SRC="/root/CloudOps/backup/"
|
||||||
VM_BACKUP_DIR="/root/CloudOps/backup"
|
VM_BACKUP_DIR="/root/CloudOps/backup"
|
||||||
|
|
||||||
|
# Same reason as backup/ above — restore_start() references this as a
|
||||||
|
# fixed absolute path (/root/CloudOps/ansible/dr-bootstrap.yml) to drive
|
||||||
|
# every "External Machine" restore now, not just true from-scratch DR.
|
||||||
|
LOCAL_ANSIBLE_SRC="/root/CloudOps/ansible/"
|
||||||
|
VM_ANSIBLE_DIR="/root/CloudOps/ansible"
|
||||||
|
|
||||||
LOG="/root/CloudOps/scripts/sync-standby-platform.log"
|
LOG="/root/CloudOps/scripts/sync-standby-platform.log"
|
||||||
|
|
||||||
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||||
@@ -82,6 +89,17 @@ if [ $? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
CHANGES="${CHANGES}${BACKUP_CHANGES}"
|
CHANGES="${CHANGES}${BACKUP_CHANGES}"
|
||||||
|
|
||||||
|
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "mkdir -p $VM_ANSIBLE_DIR"
|
||||||
|
ANSIBLE_CHANGES=$(rsync -a --delete -e "ssh $SSH_OPTS" \
|
||||||
|
--exclude tmp --exclude '*.retry' \
|
||||||
|
--itemize-changes \
|
||||||
|
"$LOCAL_ANSIBLE_SRC" "$VM_USER@$VM_HOST:$VM_ANSIBLE_DIR/" 2>>"$LOG")
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log "❌ rsync of ansible/ failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CHANGES="${CHANGES}${ANSIBLE_CHANGES}"
|
||||||
|
|
||||||
CONFIG_CHANGED=""
|
CONFIG_CHANGED=""
|
||||||
if ! scp $SCP_OPTS "$LIVE_CONFIG" "$VM_USER@$VM_HOST:$VM_DEPLOY_DIR/config.py.new" &>>"$LOG"; then
|
if ! scp $SCP_OPTS "$LIVE_CONFIG" "$VM_USER@$VM_HOST:$VM_DEPLOY_DIR/config.py.new" &>>"$LOG"; then
|
||||||
log "❌ Could not copy live config.py to VM"
|
log "❌ Could not copy live config.py to VM"
|
||||||
|
|||||||
Reference in New Issue
Block a user