Change : Backup pick

This commit is contained in:
2026-06-21 00:20:37 +01:00
parent c3e3a3b28c
commit bf9c4a01b2
3 changed files with 219 additions and 24 deletions

View File

@@ -43,6 +43,10 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
restore_jobs = {}
backup_jobs = {}
# Whitelist of apps the restore script knows how to selectively restore.
# Anything not in this set is silently dropped before it ever reaches a shell string.
ALLOWED_RESTORE_APPS = {'frappe', 'odoo', 'nextcloud', 'mautic', 'n8n'}
def _stream_restore(job_id, cmd):
restore_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()}
@@ -732,6 +736,20 @@ def restore_start():
if not backup_file:
return jsonify({'error': 'No backup file specified'}), 400
# ── Selective app restore ────────────────────────────────────
# `apps` is optional. Empty/missing => full restore (all apps).
# Anything not in ALLOWED_RESTORE_APPS is dropped before it can
# reach a shell string.
raw_apps = data.get('apps', [])
if isinstance(raw_apps, str):
raw_apps = [a.strip() for a in raw_apps.split(',') if a.strip()]
selected_apps = sorted({
a.strip().lower() for a in raw_apps
if isinstance(a, str) and a.strip().lower() in ALLOWED_RESTORE_APPS
})
apps_flag = f" --apps {','.join(selected_apps)}" if selected_apps else ""
apps_label = ','.join(selected_apps) if selected_apps else 'ALL'
# ── Resolve backup path by source ────────────────────────────
if backup_source == 'cloud':
backup_path = f"/tmp/{backup_file}"
@@ -794,12 +812,13 @@ def restore_start():
cmd = (
f"set -e && "
f"echo 'Restoring on this server ({hostname})...' && "
f"echo 'Apps: {apps_label}' && "
f"mkdir -p {session_dir} && "
f"echo 'Extracting backup...' && "
f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && "
f"cp {restore_script_local} {session_dir}/restore-myapps.sh && "
f"chmod +x {session_dir}/restore-myapps.sh && "
f"cd {session_dir} && bash restore-myapps.sh ; "
f"cd {session_dir} && bash restore-myapps.sh{apps_flag} ; "
f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT"
)
else:
@@ -821,6 +840,7 @@ def restore_start():
remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}"
cmd = (
f"echo 'Connecting to {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...' && "
@@ -831,7 +851,7 @@ def restore_start():
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-myapps.sh && bash restore-myapps.sh' ; "
f"chmod +x restore-myapps.sh && bash restore-myapps.sh{apps_flag}' ; "
f"EXIT=$? ; "
f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; "
f"exit $EXIT"

View File

@@ -16,6 +16,9 @@ REMOTE_IP=""
REMOTE_USER="root"
SSH_KEY=""
USE_PASSWORD=false
APPS_FILTER="all" # "all" or comma-separated: frappe,odoo,nextcloud,mautic,n8n
VALID_APPS="frappe odoo nextcloud mautic n8n"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -33,12 +36,37 @@ while [[ $# -gt 0 ]]; do
USE_PASSWORD=true
shift
;;
--apps)
APPS_FILTER="$2"
shift 2
;;
*)
shift
;;
esac
done
# --------------------------------------------------
# Normalize + validate --apps
# --------------------------------------------------
if [ "$APPS_FILTER" != "all" ]; then
APPS_FILTER=$(echo "$APPS_FILTER" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
IFS=',' read -ra _APP_CHECK <<< "$APPS_FILTER"
for _a in "${_APP_CHECK[@]}"; do
if [[ " $VALID_APPS " != *" $_a "* ]]; then
echo "❌ Unknown app '$_a' in --apps. Valid options: $VALID_APPS"
exit 1
fi
done
fi
# Returns 0 (true) if $1 should be restored given APPS_FILTER
app_selected() {
local app="$1"
[ "$APPS_FILTER" = "all" ] && return 0
[[ ",$APPS_FILTER," == *",${app},"* ]]
}
# --------------------------------------------------
# Remote mode
# --------------------------------------------------
@@ -74,11 +102,21 @@ if [ "$REMOTE_MODE" = true ]; then
echo "========================================="
echo "📡 REMOTE RESTORE MODE"
echo " Target: ${REMOTE_USER}@${REMOTE_IP}"
if [ "$APPS_FILTER" = "all" ]; then
echo " Apps: ALL"
else
echo " Apps: $APPS_FILTER"
fi
echo "========================================="
REMOTE_ARGS=""
if [ "$APPS_FILTER" != "all" ]; then
REMOTE_ARGS="--apps $APPS_FILTER"
fi
$SSH_CMD "mkdir -p $REMOTE_DEST"
$SCP_CMD -r "$SCRIPT_DIR/." "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DEST}/"
$SSH_CMD "chmod +x $REMOTE_DEST/restore-myapps.sh && cd $REMOTE_DEST && bash restore-myapps.sh"
$SSH_CMD "chmod +x $REMOTE_DEST/restore-myapps.sh && cd $REMOTE_DEST && bash restore-myapps.sh $REMOTE_ARGS"
$SSH_CMD "rm -rf $REMOTE_DEST"
echo "✅ Remote restore complete on $REMOTE_IP"
@@ -101,6 +139,11 @@ echo "========================================="
echo "🔄 Smart Restore — LOCAL MODE"
echo " Machine IP: $VM_IP"
echo " Backup dir: $SCRIPT_DIR"
if [ "$APPS_FILTER" = "all" ]; then
echo " Apps: ALL"
else
echo " Apps: $APPS_FILTER (forced — ignoring health checks)"
fi
echo "========================================="
container_is_healthy() {
@@ -136,14 +179,36 @@ declare -A VOLUME_OWNERS=(
["odoo-clean_odoo-etc"]="odoo-clean-odoo-1"
)
declare -A VOLUME_APP=(
["frappe-setup_frappe-sites"]="frappe"
["frappe-setup_mariadb-data"]="frappe"
["nextcloud-setup_nextcloud-data"]="nextcloud"
["nextcloud-setup_nextcloud-db-data"]="nextcloud"
["mautic-setup_mautic-data"]="mautic"
["mautic-setup_mautic-db-data"]="mautic"
["n8n-setup_n8n-data"]="n8n"
["n8n-setup_n8n-db-data"]="n8n"
["odoo-clean_db-data"]="odoo"
["odoo-clean_odoo-etc"]="odoo"
)
if [ -d "$SCRIPT_DIR/volumes" ]; then
cd "$SCRIPT_DIR/volumes"
for backup in *.tar.gz; do
[ -f "$backup" ] || continue
volume=$(basename "$backup" .tar.gz)
owner="${VOLUME_OWNERS[$volume]:-}"
vol_app="${VOLUME_APP[$volume]:-}"
if [ -n "$owner" ] && container_is_healthy "$owner"; then
if [ -n "$vol_app" ] && ! app_selected "$vol_app"; then
echo " ⏭️ $volume — app '$vol_app' not selected, skipping"
continue
fi
# Health-based protection only applies on full ("all") runs.
# An explicit --apps selection means the user wants this restored
# regardless of what the health check currently reports.
if [ "$APPS_FILTER" = "all" ] && [ -n "$owner" ] && container_is_healthy "$owner"; then
echo " ⏭️ $volume — container '$owner' is healthy, skipping"
continue
fi
@@ -186,25 +251,47 @@ declare -A APP_MAIN_CONTAINER=(
["n8n"]="n8n-app"
)
declare -A APP_KEY=(
["Frappe"]="frappe"
["Odoo"]="odoo"
["Nextcloud"]="nextcloud"
["Mautic"]="mautic"
["n8n"]="n8n"
)
if [ -d "$SCRIPT_DIR/compose-files" ]; then
cd "$SCRIPT_DIR/compose-files"
for app in Frappe Odoo Nextcloud Mautic n8n; do
app_key="${APP_KEY[$app]}"
dir="${APP_DIRS[$app]}"
main_ctr="${APP_MAIN_CONTAINER[$app]}"
if container_is_healthy "$main_ctr"; then
if ! app_selected "$app_key"; then
echo " ⏭️ $app — not selected, skipping"
continue
fi
if [ ! -d "$dir" ]; then
echo " ⏭️ $app compose dir '$dir' not found, skipping"
continue
fi
if [ "$APPS_FILTER" = "all" ] && container_is_healthy "$main_ctr"; then
echo " ⏭️ $app — already running and healthy, skipping"
continue
fi
if [ -d "$dir" ]; then
echo " 🚀 Starting $app..."
cd "$dir"
docker-compose up -d 2>&1 | tail -3
cd ..
cd "$dir"
if [ "$APPS_FILTER" != "all" ] && container_is_healthy "$main_ctr"; then
# Already running — up -d alone won't reload the freshly restored
# volume, so force a recreate.
echo " 🔁 $app — explicitly selected, recreating to load restored data..."
docker-compose down 2>&1 | tail -3
else
echo " ⏭️ $app compose dir '$dir' not found, skipping"
echo " 🚀 Starting $app..."
fi
docker-compose up -d 2>&1 | tail -3
cd ..
done
cd "$SCRIPT_DIR"
else
@@ -226,7 +313,9 @@ echo "========================================="
# ---- NEXTCLOUD ----
echo ""
echo "📌 Nextcloud — Trusted domains..."
if container_is_healthy nextcloud-app; then
if ! app_selected nextcloud; then
echo " ⏭️ Nextcloud not selected, skipping"
elif [ "$APPS_FILTER" = "all" ] && container_is_healthy nextcloud-app; then
echo " ⏭️ Nextcloud is healthy, skipping fix"
else
if docker ps | grep -q nextcloud-app; then
@@ -250,7 +339,9 @@ fi
# ---- MAUTIC ----
echo ""
echo "📌 Mautic — Config + admin password..."
if container_is_healthy mautic-app; then
if ! app_selected mautic; then
echo " ⏭️ Mautic not selected, skipping"
elif [ "$APPS_FILTER" = "all" ] && container_is_healthy mautic-app; then
echo " ⏭️ Mautic is healthy, skipping fix"
else
if docker ps | grep -q mautic-app; then
@@ -288,7 +379,9 @@ fi
# ---- ODOO ----
echo ""
echo "📌 Odoo — Assets + DB + Config..."
if container_is_healthy odoo-clean-odoo-1; then
if ! app_selected odoo; then
echo " ⏭️ Odoo not selected, skipping"
elif [ "$APPS_FILTER" = "all" ] && container_is_healthy odoo-clean-odoo-1; then
echo " ⏭️ Odoo is healthy, skipping fix"
else
if docker ps | grep -q odoo-clean-db-1; then
@@ -325,7 +418,9 @@ fi
# ---- FRAPPE ----
echo ""
echo "📌 Frappe — DB permissions + cache + URL..."
if container_is_healthy frappe-erpnext; then
if ! app_selected frappe; then
echo " ⏭️ Frappe not selected, skipping"
elif [ "$APPS_FILTER" = "all" ] && container_is_healthy frappe-erpnext; then
echo " ⏭️ Frappe is healthy, skipping fix"
else
if docker ps | grep -q frappe-erpnext && docker ps | grep -q frappe-mariadb; then
@@ -363,7 +458,9 @@ fi
# ---- N8N ----
echo ""
echo "📌 n8n — Network check..."
if container_is_healthy n8n-app; then
if ! app_selected n8n; then
echo " ⏭️ n8n not selected, skipping"
elif [ "$APPS_FILTER" = "all" ] && container_is_healthy n8n-app; then
echo " ⏭️ n8n is healthy, skipping fix"
else
docker network inspect integration-network &>/dev/null \
@@ -388,9 +485,9 @@ echo ""
echo "========================================="
echo "✅ RESTORE COMPLETE"
echo "========================================="
echo " Nextcloud → http://${VM_IP}:8082"
echo " Mautic → http://${VM_IP}:8081/s/login (admin / Admin!Password123)"
echo " Odoo → https://odooo.nav.ovh/web"
echo " n8n → http://${VM_IP}:5678"
echo " Frappe → http://${VM_IP}:8080"
if app_selected nextcloud; then echo " Nextcloud → http://${VM_IP}:8082"; fi
if app_selected mautic; then echo " Mautic → http://${VM_IP}:8081/s/login (admin / Admin!Password123)"; fi
if app_selected odoo; then echo " Odoo → https://odooo.nav.ovh/web"; fi
if app_selected n8n; then echo " n8n → http://${VM_IP}:5678"; fi
if app_selected frappe; then echo " Frappe → http://${VM_IP}:8080"; fi
echo "========================================="

View File

@@ -113,11 +113,48 @@
</div>
</div>
<div class="form-section">
<div class="form-section-title">STEP 3 — SELECT APPS TO RESTORE</div>
<p style="color:var(--text3); font-size:12px; margin-bottom:10px;">
All apps are restored by default. Uncheck the ones you don't want touched —
only the checked apps will be restored, and they're force-restored even if
currently running and healthy.
</p>
<div id="apps-checkbox-group" style="display:flex; flex-wrap:wrap; gap:8px;">
<label class="radio-card small" style="cursor:pointer;">
<input type="checkbox" value="frappe" checked style="margin-right:6px;">
<span>🧾 Frappe / ERPNext</span>
</label>
<label class="radio-card small" style="cursor:pointer;">
<input type="checkbox" value="odoo" checked style="margin-right:6px;">
<span>🟣 Odoo</span>
</label>
<label class="radio-card small" style="cursor:pointer;">
<input type="checkbox" value="nextcloud" checked style="margin-right:6px;">
<span>☁️ Nextcloud</span>
</label>
<label class="radio-card small" style="cursor:pointer;">
<input type="checkbox" value="mautic" checked style="margin-right:6px;">
<span>📣 Mautic</span>
</label>
<label class="radio-card small" style="cursor:pointer;">
<input type="checkbox" value="n8n" checked style="margin-right:6px;">
<span>🔁 n8n</span>
</label>
</div>
<div style="margin-top:10px; display:flex; gap:8px;">
<button type="button" class="btn btn-sm" onclick="nvSetAllApps(true)">Select All</button>
<button type="button" class="btn btn-sm" onclick="nvSetAllApps(false)">Select None</button>
</div>
</div>
<div class="form-section">
<button class="btn btn-danger btn-lg" onclick="nvLaunchRestore()" id="restore-btn">
<i class="fas fa-play"></i> Start Restore
</button>
<p style="color:var(--text3); font-size:12px; margin-top:8px;">⚠ Healthy running containers are skipped.</p>
<p style="color:var(--text3); font-size:12px; margin-top:8px;" id="restore-warning-text">
⚠ Full restore (all apps checked): healthy running containers are skipped.
</p>
</div>
</div>
@@ -182,11 +219,48 @@ function nvGetSelection() {
return { source, file, key };
}
// ── App selection (Step 3) ────────────────────────────────────────────────────
function nvGetAppsCheckboxes() {
return Array.from(document.querySelectorAll('#apps-checkbox-group input[type="checkbox"]'));
}
// Returns [] when every app is checked (= full restore, no filter needed),
// otherwise returns the list of explicitly checked app keys.
function nvGetSelectedApps() {
const boxes = nvGetAppsCheckboxes();
const checked = boxes.filter(b => b.checked).map(b => b.value);
return checked.length === boxes.length ? [] : checked;
}
function nvSetAllApps(state) {
nvGetAppsCheckboxes().forEach(b => b.checked = state);
nvUpdateRestoreWarning();
}
function nvUpdateRestoreWarning() {
const selected = nvGetSelectedApps();
const el = document.getElementById('restore-warning-text');
if (selected.length === 0) {
el.textContent = '⚠ Full restore (all apps checked): healthy running containers are skipped.';
} else {
el.textContent = `⚠ Selective restore: only ${selected.join(', ')} will be touched — forced even if currently healthy.`;
}
}
document.addEventListener('change', (e) => {
if (e.target.closest('#apps-checkbox-group')) nvUpdateRestoreWarning();
});
// ── Launch restore ────────────────────────────────────────────────────────────
async function nvLaunchRestore() {
const { source, file, key } = nvGetSelection();
if (!file) { alert('Please select a backup file.'); return; }
const appsCheckboxes = nvGetAppsCheckboxes();
const checkedCount = appsCheckboxes.filter(b => b.checked).length;
if (checkedCount === 0) { alert('Select at least one app to restore.'); return; }
const selectedApps = nvGetSelectedApps();
const target = document.querySelector('input[name="restore_target"]:checked')?.value || 'local';
const authMethod = document.querySelector('input[name="auth_method"]:checked')?.value || 'key';
@@ -194,6 +268,7 @@ async function nvLaunchRestore() {
backup_source: source,
backup_file: file,
cloud_key: key,
apps: selectedApps,
target,
remote_ip: document.getElementById('remote-ip')?.value || '',
remote_port: document.getElementById('remote-port')?.value || '22',
@@ -211,9 +286,12 @@ async function nvLaunchRestore() {
btn.disabled = true;
wrapper.style.display = '';
const scopeMsg = selectedApps.length
? `🎯 Restoring only: ${selectedApps.join(', ')}`
: '🚀 Starting full restore…';
logEl.innerHTML = source === 'cloud'
? '<div style="color:var(--accent2)">☁️ Downloading backup from R2, please wait…</div>'
: '<div style="color:var(--accent2)">🚀 Starting restore…</div>';
? `<div style="color:var(--accent2)">☁️ Downloading backup from R2, please wait…</div><div style="color:var(--accent2)">${scopeMsg}</div>`
: `<div style="color:var(--accent2)">${scopeMsg}</div>`;
badge.textContent = 'Running…';
badge.style.cssText = 'background:rgba(59,130,246,0.15);color:var(--accent2);';