Compare commits

...

5 Commits

Author SHA1 Message Date
root
9b631f527c Show which apps are in each backup + optional per-app manual backup
Prompted by today's testing: running backup-k8s-apps.sh --apps X repeatedly
for isolated per-app verification left a bunch of single-app archives in
the list, all named identically (myapps-k8s-backup-TIMESTAMP.tar.gz) with
no visible indication of which app(s) each one actually contains — the
data was already there (the .meta.json sidecar's `apps` field, used for
the status-dot rolling average) but only surfaced in a hover tooltip.
Cleaned up today's test archives (local + VM + R2) and ran one fresh full
backup so the list reflects real state.

Two things added, backup/restore logic itself untouched per explicit
instruction:

1. A small visible badge next to each backup's name — "All apps" for a
   full bundle, or the specific list (e.g. "odoo, n8n") for a partial one
   — in both the Jinja-rendered list and platform.js's refresh path.

2. An optional app-picker on the "Run Backup Now" manual trigger, same
   checkbox-grid pattern already used on the Restore page's app selector.
   All checked (default) = exactly today's existing behavior, no --apps
   flag, everything bundled into one archive. Unchecking some = a
   deliberate one-off partial backup (--apps a,b) for e.g. backing up just
   odoo before a risky change without waiting on the other 4. The nightly
   cron and "backup all together" behavior are completely unaffected —
   this only touches the manual/UI-triggered path.

Verified: manual full backup still produces one bundled archive (249M, 5
apps); the apps-badge renders correctly ("All apps") on the live standby;
the --apps arg-construction logic unit-tested directly (partial selection
-> ['--apps', 'a,b'], all-selected -> [] i.e. default bundle).
2026-08-21 15:15:13 +02:00
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
root
7ddce24e62 Fix local-column status dots always showing gray on the standby
get_local_backups_with_status() called _load_local_sidecars() directly,
which is a plain glob.glob('/root/backups/*.tar.gz.meta.json') with no
SSH-fallback awareness at all — unlike get_local_backups() itself (fixed
earlier this session) and _fetch_vm_sidecars() (already had this pattern
from day one). On the standby, /root/backups doesn't exist locally, so
the glob silently returned {} and every local-column entry fell through
to the 'unknown' (gray) default regardless of its real status, while the
VM column worked correctly since _fetch_vm_sidecars() already had the
fallback.

Added _fetch_local_sidecars() mirroring _fetch_vm_sidecars()'s exact
local-dir-first-else-SSH pattern, targeting /root/backups on the main
server via the now-working tunnel instead of the VM's own backup dir.

Verified on the live standby: local column now shows green for backups
with a sidecar and gray only for the legacine ones that genuinely
predate the sidecar feature (documented, intended behavior) — not gray
across the board.
2026-08-21 14:32:01 +02:00
root
054a9e1fcb Add Ansible-driven DR bootstrap (#9) + PVC capture in backups
restore-k8s-apps.sh's own header explicitly scoped out fresh-cluster
provisioning ("assumes the target cluster/namespaces/PVCs already
exist"). This adds exactly that missing piece as an Ansible playbook
rather than another bash script — a better fit for "provision a target
into a working state" than for the actual data-restore logic, which stays
in restore-k8s-apps.sh unchanged and is just invoked as the final step.

dr-bootstrap.yml, given an app name + a backup archive:
  1. Checks the target's environment (disk/mem/cpu) against k3s minimums
     before touching anything
  2. Installs k3s only if not already present (checked, not assumed)
  3. Creates the namespace, PVC(s), Secret, and Deployments/Services/
     Ingress from the backup's captured manifests — sanitized first
     (sanitize_k8s_manifest.py strips resourceVersion/uid/status/
     volumeName, the same class of bug as the earlier Secret-apply fix,
     generalized) since a raw `kubectl get -o yaml` dump can't be
     reapplied to a different cluster as-is
  4. Hands off to the existing, already-validated restore-k8s-apps.sh for
     the actual data population

backup-k8s-apps.sh gets a small but necessary addition: PVC specs were
never captured at all before (only Deployment/Service/ConfigMap/Ingress),
so there was nothing for a fresh-cluster bootstrap to provision the PVC
from. Captured with the same label selector as the existing manifests
capture, so it stays in lockstep automatically.

Verified for real, not just syntax-checked: ran the full playbook against
the standby VM (freshly installed k3s, empty namespace) using a live n8n
backup. Namespace/PVCs/Secret/Deployments provisioned from nothing, data
restored, pod healthy, and the workflow count in the restored Postgres DB
(11) matched the real source server exactly.
2026-08-21 14:30:58 +02:00
root
459c2974f0 Add VM-side backup retention (was completely unbounded)
Checking disk usage on the VM (95% full, blocking the planned k3s
install) found /backups/cloudproject had 85 legacy Docker-era
myapps-backup-* archives totaling 131GB, dating back to May — the whole
tier had never had any retention at all, only local (TIER 1) and R2
(TIER 3) did. Those 85 are from before the k8s migration entirely (the
Docker volumes they captured don't exist anymore), so deleted all of
them directly on the VM rather than just trimming a few.

Added retention to the VM upload step mirroring the existing local
policy exactly: same MAX_BACKUPS, own myapps-k8s-backup-* glob only —
never touches the other unrelated backup directories that also live on
this VM (imcbackups/mpack/garage, other servers' backups, already
separately reviewed and left alone except a manual one-off trim of
garage's uncapped 31-day history down to 10).

Disk usage: 183G/193G (95%) -> 50G/193G (26%) after cleanup.
2026-08-21 14:07:22 +02:00
10 changed files with 447 additions and 28 deletions

179
ansible/dr-bootstrap.yml Normal file
View 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
View 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'

View 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()

View File

@@ -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}"

View File

@@ -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())

View File

@@ -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():

View File

@@ -2,3 +2,4 @@ boto3==1.43.5
flask
psutil
kubernetes
ansible-core

View File

@@ -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;

View File

@@ -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>

View File

@@ -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"