Wire management-platform's backup/restore routes to the k8s scripts

api_backup_run and restore_start were still hardcoded to the Docker-era
backup-myapps.sh/restore-myapps.sh, which target Docker volumes that no
longer exist for the 5 apps now running in k3s (n8n, odoo, mautic,
nextcloud, frappe/erpnext) — every backup/restore triggered from the UI
has been silently hollow for these apps since the migration.

- api_backup_run: /root/backup-myapps.sh -> /root/CloudOps/backup/backup-k8s-apps.sh
- restore_start: switched from the old image-relative path (which resolved
  to /app/restore-myapps.sh, baked into the Docker image from platform/)
  to an absolute /root/CloudOps/backup/restore-k8s-apps.sh path — the new
  backup/ folder is a sibling of platform/, not part of the Docker build
  context, so it's only reachable via the pod's existing /root hostPath
  mount, the same way api_backup_run already reaches its script.

--apps flag shape and the job-log streaming contract are unchanged, so no
frontend/template changes needed for the core flow. modules/backups.py's
three hardcoded myapps-backup-* references (get_local_backups, get_vm_backups
x2, delete_backup's filename regex) now also recognize the new
myapps-k8s-backup-* prefix, so both backup lineages stay visible/manageable
in the UI.

Depends on the previous commit (scoped ServiceAccount + k3s kubectl access
for the pod) to actually function when triggered from the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-08-20 11:30:14 +02:00
parent f3f08c3ef6
commit a6fb9e5e18
2 changed files with 29 additions and 14 deletions

View File

@@ -424,7 +424,7 @@ def api_backup_run():
'message': 'Manual backup can only be triggered from the main server platform.' 'message': 'Manual backup can only be triggered from the main server platform.'
}), 400 }), 400
script = '/root/backup-myapps.sh' script = '/root/CloudOps/backup/backup-k8s-apps.sh'
if not os.path.exists(script): if not os.path.exists(script):
return jsonify({ return jsonify({
'success': False, 'success': False,
@@ -818,11 +818,14 @@ def restore_start():
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
return jsonify({'error': f'Not found: {backup_path}'}), 400 return jsonify({'error': f'Not found: {backup_path}'}), 400
restore_script_local = os.path.join( # Absolute /root path (not image-relative like the old restore-myapps.sh
os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh' # was) — backup/restore-k8s-apps.sh lives in the CloudOps repo's backup/
) # folder, a sibling of platform/, so it's never baked into the Docker
# image; it's reached the same way api_backup_run's script is, via the
# pod's existing rw hostPath mount of /root.
restore_script_local = '/root/CloudOps/backup/restore-k8s-apps.sh'
if not os.path.exists(restore_script_local): if not os.path.exists(restore_script_local):
return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500 return jsonify({'error': f'restore-k8s-apps.sh not found at {restore_script_local}'}), 500
if target == 'local': if target == 'local':
hostname = os.uname().nodename hostname = os.uname().nodename
@@ -835,9 +838,9 @@ def restore_start():
f"mkdir -p {session_dir} && " f"mkdir -p {session_dir} && "
f"echo 'Extracting backup...' && " f"echo 'Extracting backup...' && "
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && " f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && " f"cp {restore_script_local} {session_dir}/restore-k8s-apps.sh && "
f"chmod +x {session_dir}/restore-myapps.sh && " f"chmod +x {session_dir}/restore-k8s-apps.sh && "
f"cd {session_dir} && bash restore-myapps.sh{apps_flag} ; " f"cd {session_dir} && bash restore-k8s-apps.sh{apps_flag} ; "
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT" f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
) )
else: else:
@@ -865,12 +868,12 @@ def restore_start():
f"echo 'Copying backup archive...' && " f"echo 'Copying backup archive...' && "
f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && " f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && "
f"echo 'Copying restore script...' && " f"echo 'Copying restore script...' && "
f"{scp_prefix} {restore_script_local} {remote_user}@{remote_ip}:{remote_dest}/restore-myapps.sh && " 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"echo 'Running restore on {remote_ip}:{remote_port}...' && "
f"{ssh_prefix} {remote_user}@{remote_ip} " f"{ssh_prefix} {remote_user}@{remote_ip} "
f"'set -e && cd {remote_dest} && " f"'set -e && cd {remote_dest} && "
f"tar -xzf {backup_file} --strip-components=1 && " f"tar -xzf {backup_file} --strip-components=1 && "
f"chmod +x restore-myapps.sh && bash restore-myapps.sh{apps_flag}' ; " f"chmod +x restore-k8s-apps.sh && bash restore-k8s-apps.sh{apps_flag}' ; "
f"EXIT=$? ; " f"EXIT=$? ; "
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; " f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
f"exit $EXIT" f"exit $EXIT"

View File

@@ -56,8 +56,12 @@ def _ssh_main(remote_cmd, timeout=30):
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
def get_local_backups(): def get_local_backups():
# myapps-backup-* is the legacy Docker-era lineage; myapps-k8s-backup-*
# is the k8s-native one (backup-k8s-apps.sh). Listed together, sorted
# by mtime, so history from both stays visible in the UI.
stdout, _ = _ssh_main( stdout, _ = _ssh_main(
"ls -t /root/backups/myapps-backup-*.tar.gz 2>/dev/null | head -20" "ls -t /root/backups/myapps-backup-*.tar.gz "
"/root/backups/myapps-k8s-backup-*.tar.gz 2>/dev/null | head -20"
) )
files = [] files = []
if stdout: if stdout:
@@ -77,7 +81,8 @@ def get_vm_backups():
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"-o BatchMode=yes " f"-o BatchMode=yes "
f"{VM_USER}@{VM_HOST} " f"{VM_USER}@{VM_HOST} "
f"'ls -t /backups/cloudproject/myapps-backup-*.tar.gz 2>/dev/null | head -20'" f"'ls -t /backups/cloudproject/myapps-backup-*.tar.gz "
f"/backups/cloudproject/myapps-k8s-backup-*.tar.gz 2>/dev/null | head -20'"
) )
stdout, _ = _run(cmd, timeout=25) stdout, _ = _run(cmd, timeout=25)
if stdout: if stdout:
@@ -90,7 +95,10 @@ def get_vm_backups():
else: else:
backup_dir = '/backups/cloudproject' backup_dir = '/backups/cloudproject'
if os.path.exists(backup_dir): if os.path.exists(backup_dir):
files = glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz') files = (
glob.glob(f'{backup_dir}/myapps-backup-*.tar.gz')
+ glob.glob(f'{backup_dir}/myapps-k8s-backup-*.tar.gz')
)
files.sort(key=os.path.getmtime, reverse=True) files.sort(key=os.path.getmtime, reverse=True)
vm_backups = [os.path.basename(f) for f in files[:20]] vm_backups = [os.path.basename(f) for f in files[:20]]
return vm_backups return vm_backups
@@ -408,7 +416,11 @@ def _sha256_file(path):
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
def delete_backup(backup_file, source='local'): def delete_backup(backup_file, source='local'):
if not re.match(r'^myapps-backup-\d{8}_\d{6}\.tar\.gz$', backup_file): # Accepts both the legacy Docker-era lineage (myapps-backup-*) and the
# k8s-native one (myapps-k8s-backup-*, from backup-k8s-apps.sh). Still a
# tight anchor match — this guards against path traversal in a filename
# that ends up interpolated into a shell/filesystem path below.
if not re.match(r'^myapps-(k8s-)?backup-\d{8}_\d{6}\.tar\.gz$', backup_file):
return False, f'Invalid backup filename: {backup_file}' return False, f'Invalid backup filename: {backup_file}'
if source == 'local': if source == 'local':