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 "⚠️ 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) ----
|
||||
echo -n " 🔑 Secret ($secret_name) ... "
|
||||
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" \
|
||||
"${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
||||
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
|
||||
echo " ⚠️ VM transfer failed (rc=$VM_SCP_RC) — local backup is safe, continuing..."
|
||||
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 re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
import time
|
||||
@@ -69,11 +70,11 @@ def _stream_restore(job_id, cmd):
|
||||
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()}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
['bash', script_path],
|
||||
['bash', script_path] + (extra_args or []),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1
|
||||
)
|
||||
@@ -441,8 +442,21 @@ def api_backup_run():
|
||||
'message': f'Backup script not found at {script}'
|
||||
}), 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())
|
||||
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()
|
||||
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
|
||||
|
||||
@@ -869,36 +883,74 @@ def restore_start():
|
||||
if not remote_ip:
|
||||
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 not ssh_key_path:
|
||||
return jsonify({'error': 'ssh_key_path required'}), 400
|
||||
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_opts}"
|
||||
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_opts}"
|
||||
common_evars.append(f"ansible_ssh_private_key_file={ssh_key_path}")
|
||||
else:
|
||||
if not ssh_password:
|
||||
return jsonify({'error': 'ssh_password required'}), 400
|
||||
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_opts}"
|
||||
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_opts}"
|
||||
common_evars.append(f"ansible_password='{ssh_password}'")
|
||||
|
||||
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 = (
|
||||
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"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
|
||||
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"
|
||||
+ " && ".join(per_app_cmds)
|
||||
)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
@@ -241,8 +241,26 @@ def _with_status(names, sidecars):
|
||||
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():
|
||||
return _with_status(get_local_backups(), _load_local_sidecars())
|
||||
return _with_status(get_local_backups(), _fetch_local_sidecars())
|
||||
|
||||
|
||||
def get_vm_backups_with_status():
|
||||
|
||||
@@ -2,3 +2,4 @@ boto3==1.43.5
|
||||
flask
|
||||
psutil
|
||||
kubernetes
|
||||
ansible-core
|
||||
@@ -410,11 +410,15 @@ function renderBackupList(items, id, source) {
|
||||
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>`
|
||||
: '';
|
||||
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 `
|
||||
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
||||
<div class="backup-item-main">
|
||||
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
||||
<span class="backup-name">${escapeHtml(name)}</span>
|
||||
${appsLabel}
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
${detailsBtn}
|
||||
@@ -489,7 +493,14 @@ async function runManualBackup() {
|
||||
if (wrapper) wrapper.style.display = '';
|
||||
if (logEl) logEl.innerHTML = '';
|
||||
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();
|
||||
if (!d.success) throw new Error(d.message || 'Failed');
|
||||
manualBackupJobId = d.job_id;
|
||||
|
||||
@@ -252,7 +252,52 @@
|
||||
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
||||
</div>
|
||||
<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">
|
||||
<i class="fas fa-play"></i> Run Backup Now
|
||||
</button>
|
||||
@@ -288,6 +333,11 @@
|
||||
<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="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 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>
|
||||
@@ -313,6 +363,11 @@
|
||||
<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="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 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>
|
||||
|
||||
@@ -44,6 +44,13 @@ LIVE_CONFIG="/root/management-platform/config.py"
|
||||
# here to scp across, so mirror it too, not just platform/.
|
||||
LOCAL_BACKUP_SRC="/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"
|
||||
|
||||
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||
@@ -82,6 +89,17 @@ if [ $? -ne 0 ]; then
|
||||
fi
|
||||
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=""
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user