Compare commits
8 Commits
de0b1ee4db
...
9361b9c4c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9361b9c4c1 | ||
|
|
11b9f6f0c7 | ||
|
|
4dfd512785 | ||
|
|
5329799a5a | ||
|
|
995e07c894 | ||
|
|
fca77d92b5 | ||
|
|
72dba7a3a4 | ||
|
|
d498c81d5b |
@@ -96,6 +96,15 @@ declare -A SECRET_NAME=( [n8n]=n8n-secrets [odoo]=odoo-secrets [mautic]=ma
|
||||
declare -A DB_USER_KEY=( [n8n]=DB_POSTGRESDB_USER [odoo]=POSTGRES_USER [mautic]=MYSQL_USER [nextcloud]=POSTGRES_USER [frappe]="" )
|
||||
declare -A DB_PASS_KEY=( [n8n]=DB_POSTGRESDB_PASSWORD [odoo]=POSTGRES_PASSWORD [mautic]=MYSQL_ROOT_PASSWORD [nextcloud]=POSTGRES_PASSWORD [frappe]=MARIADB_ROOT_PASSWORD )
|
||||
|
||||
# Reproducible framework/vendor code that lives on the same PVC as real user
|
||||
# data for these two apps (single shared mount, no separate volume). Both
|
||||
# images bake these dirs into their own filesystem layer independent of any
|
||||
# volume — Mautic has no logic to reconstruct them, so restore-k8s-apps.sh
|
||||
# re-seeds them straight from the app's own image instead of from backup.
|
||||
# Never pull request/exclude changes here without updating the matching
|
||||
# RECONSTRUCT_DIRS table in restore-k8s-apps.sh.
|
||||
declare -A TAR_EXCLUDES=( [mautic]="node_modules vendor" [nextcloud]="apps core dist 3rdparty lib" )
|
||||
|
||||
ALL_APPS="n8n odoo mautic nextcloud frappe"
|
||||
|
||||
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
|
||||
@@ -174,6 +183,83 @@ check_resources() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------
|
||||
# Per-backup status metadata sidecar (.meta.json) — powers the UI's
|
||||
# green/yellow/red status dot without ever needing to decompress the
|
||||
# (possibly 600MB+) archive itself. Written only after all 3 tiers are
|
||||
# known, then best-effort mirrored to VM/R2 alongside the archive, same
|
||||
# as the .sha256 sidecar already is — so status is readable from
|
||||
# whichever tier the UI is listing, not just local.
|
||||
# --------------------------------------------------
|
||||
write_meta_sidecar() {
|
||||
local size_bytes meta_path
|
||||
size_bytes=$(stat -c%s "$BACKUP_ARCHIVE" 2>/dev/null || echo 0)
|
||||
meta_path="/root/backups/${BACKUP_NAME}.tar.gz.meta.json"
|
||||
|
||||
python3 - "$BACKUP_NAME" "$size_bytes" "$TIER1_STATUS" "$TIER2_STATUS" "$TIER3_STATUS" \
|
||||
"${#INCLUDED_APPS[@]}" "${INCLUDED_APPS[@]}" "${ERRORS[@]}" > "$meta_path" <<'PYEOF'
|
||||
import sys, json, datetime
|
||||
|
||||
backup_name = sys.argv[1]
|
||||
size_bytes = int(sys.argv[2])
|
||||
tier1, tier2, tier3 = sys.argv[3], sys.argv[4], sys.argv[5]
|
||||
n_apps = int(sys.argv[6])
|
||||
apps = sys.argv[7:7 + n_apps]
|
||||
errors_raw = sys.argv[7 + n_apps:]
|
||||
|
||||
def norm_tier(s):
|
||||
# "ok:572M" -> "ok"; "failed:rc=2" / "skipped:no_credentials" pass through
|
||||
# unchanged so the UI can tell a real failure apart from a deliberate skip.
|
||||
return 'ok' if s.startswith('ok') else s
|
||||
|
||||
errors = []
|
||||
for e in errors_raw:
|
||||
parts = e.split(':', 2)
|
||||
errors.append({
|
||||
'app': parts[0] if len(parts) > 0 else '',
|
||||
'step': parts[1] if len(parts) > 1 else '',
|
||||
'detail': parts[2] if len(parts) > 2 else '',
|
||||
})
|
||||
|
||||
meta = {
|
||||
'backup_name': backup_name,
|
||||
'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'apps': apps,
|
||||
'apps_key': ','.join(sorted(apps)),
|
||||
'size_bytes': size_bytes,
|
||||
'errors': errors,
|
||||
'tiers': {'local': norm_tier(tier1), 'vm': norm_tier(tier2), 'r2': norm_tier(tier3)},
|
||||
}
|
||||
json.dump(meta, sys.stdout, indent=2)
|
||||
PYEOF
|
||||
|
||||
if [ -s "$meta_path" ]; then
|
||||
echo " ✅ Metadata sidecar written: $(basename "$meta_path")"
|
||||
else
|
||||
echo " ⚠️ Metadata sidecar write failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Best-effort mirror to whichever tiers actually succeeded — never fatal,
|
||||
# the local sidecar (already written above) is what matters most.
|
||||
if [ "$TIER2_STATUS" = "ok" ]; then
|
||||
scp -i "$VM_KEY" -P "$VM_PORT" -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||
"$meta_path" "${VM_USER}@${VM_HOST}:${VM_DEST}" &>/dev/null || true
|
||||
fi
|
||||
if [ "$TIER3_STATUS" = "ok" ] && [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
|
||||
META_SCRIPT="/tmp/r2_meta_$$.py"
|
||||
cat > "$META_SCRIPT" << PYEOF2
|
||||
import boto3
|
||||
client = boto3.client('s3', endpoint_url="${R2_ENDPOINT}",
|
||||
aws_access_key_id="${AWS_ACCESS_KEY_ID}", aws_secret_access_key="${AWS_SECRET_ACCESS_KEY}",
|
||||
region_name='auto')
|
||||
client.upload_file("$meta_path", "${R2_BUCKET}", "backups/${BACKUP_NAME}.tar.gz.meta.json")
|
||||
PYEOF2
|
||||
"$PYTHON_BIN" "$META_SCRIPT" &>/dev/null || true
|
||||
rm -f "$META_SCRIPT"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "========================================="
|
||||
echo "📦 Starting k8s Backup: $BACKUP_NAME"
|
||||
if [ "$APPS_FILTER" = "all" ]; then
|
||||
@@ -189,8 +275,11 @@ mkdir -p "$BACKUP_DIR" "/root/backups"
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
APP_COUNT=0
|
||||
INCLUDED_APPS=() # apps actually attempted this run — feeds the .meta.json sidecar's apps_key
|
||||
ERRORS=() # "app:step:detail" entries — any entry here makes the sidecar's status red
|
||||
for app in $ALL_APPS; do
|
||||
app_selected "$app" || continue
|
||||
INCLUDED_APPS+=("$app")
|
||||
ns="${NS[$app]}"
|
||||
app_deploy="${APP_DEPLOY[$app]}"
|
||||
app_ctr="${APP_CTR[$app]}"
|
||||
@@ -214,13 +303,15 @@ for app in $ALL_APPS; do
|
||||
echo -n " 📄 Manifests (deployment/service/configmap/ingress) ... "
|
||||
kubectl get deployment,service,configmap,ingress -n "$ns" -l owner=ameni-boukattaya -o yaml \
|
||||
> "$APP_DIR/manifests.yaml" 2>"$APP_DIR/manifests.err" \
|
||||
&& { echo "✅"; rm -f "$APP_DIR/manifests.err"; } || echo "⚠️ FAILED (see 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)"); }
|
||||
|
||||
# ---- 2. Secret (plaintext, encryption-at-rest deferred) ----
|
||||
echo -n " 🔑 Secret ($secret_name) ... "
|
||||
kubectl get secret "$secret_name" -n "$ns" -o yaml \
|
||||
> "$APP_DIR/secret.yaml" 2>"$APP_DIR/secret.err" \
|
||||
&& { echo "✅"; rm -f "$APP_DIR/secret.err"; } || echo "⚠️ FAILED (see secret.err)"
|
||||
&& { echo "✅"; rm -f "$APP_DIR/secret.err"; } \
|
||||
|| { echo "⚠️ FAILED (see secret.err)"; ERRORS+=("$app:secret:$(tr '\n' ' ' < "$APP_DIR/secret.err" | cut -c1-200)"); }
|
||||
|
||||
# ---- 3. DB dump via kubectl exec (never raw datadir copy) ----
|
||||
echo -n " 💾 DB dump ($db_engine) ... "
|
||||
@@ -238,9 +329,11 @@ for app in $ALL_APPS; do
|
||||
rm -f "$APP_DIR/db-dump.err"
|
||||
else
|
||||
echo "⚠️ FAILED (rc=$pg_dump_rc, see db-dump.err)"
|
||||
ERRORS+=("$app:db_dump:rc=$pg_dump_rc $(tr '\n' ' ' < "$APP_DIR/db-dump.err" 2>/dev/null | cut -c1-160)")
|
||||
fi
|
||||
else
|
||||
echo "⚠️ could not read DB credentials from secret"
|
||||
ERRORS+=("$app:db_dump:could not read DB credentials from secret")
|
||||
fi
|
||||
;;
|
||||
mariadb)
|
||||
@@ -266,24 +359,33 @@ for app in $ALL_APPS; do
|
||||
rm -f "$APP_DIR/db-dump.err"
|
||||
else
|
||||
echo "⚠️ FAILED (rc=$mysqldump_rc, see db-dump.err)"
|
||||
ERRORS+=("$app:db_dump:rc=$mysqldump_rc $(tr '\n' ' ' < "$APP_DIR/db-dump.err" 2>/dev/null | cut -c1-160)")
|
||||
fi
|
||||
else
|
||||
echo "⚠️ could not determine DB name/credentials"
|
||||
ERRORS+=("$app:db_dump:could not determine DB name/credentials")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---- 4. App-data PVC contents via kubectl exec (live tar, app-data only,
|
||||
# never raw filesystem/PVC-path access — §5 sandbox-divergence rule) ----
|
||||
echo -n " 📦 PVC data ($pvc_path) ... "
|
||||
exclude_args=()
|
||||
if [ -n "${TAR_EXCLUDES[$app]:-}" ]; then
|
||||
for _excl_dir in ${TAR_EXCLUDES[$app]}; do
|
||||
exclude_args+=(--exclude="./$_excl_dir")
|
||||
done
|
||||
fi
|
||||
echo -n " 📦 PVC data ($pvc_path${exclude_args:+, excl: ${TAR_EXCLUDES[$app]}}) ... "
|
||||
kubectl exec -n "$ns" "deploy/${app_deploy}" -c "$app_ctr" -- \
|
||||
tar czf - -C "$pvc_path" . 2>"$APP_DIR/pvc-data.err" \
|
||||
tar czf - "${exclude_args[@]}" -C "$pvc_path" . 2>"$APP_DIR/pvc-data.err" \
|
||||
> "$APP_DIR/pvc-data.tar.gz"
|
||||
if [ -s "$APP_DIR/pvc-data.tar.gz" ]; then
|
||||
echo "✅ ($(du -h "$APP_DIR/pvc-data.tar.gz" | cut -f1))"
|
||||
rm -f "$APP_DIR/pvc-data.err"
|
||||
else
|
||||
echo "⚠️ FAILED (see pvc-data.err)"
|
||||
ERRORS+=("$app:pvc_data:$(tr '\n' ' ' < "$APP_DIR/pvc-data.err" 2>/dev/null | cut -c1-160)")
|
||||
fi
|
||||
|
||||
APP_COUNT=$((APP_COUNT + 1))
|
||||
@@ -354,7 +456,7 @@ if [ "$ARCHIVE_COUNT" -gt "$MAX_BACKUPS" ]; then
|
||||
TO_DELETE=$(echo "$ARCHIVE_LIST" | tail -n +$((MAX_BACKUPS + 1)))
|
||||
while IFS= read -r old_file; do
|
||||
[ -z "$old_file" ] && continue
|
||||
rm -f "$old_file" "${old_file}.sha256"
|
||||
rm -f "$old_file" "${old_file}.sha256" "${old_file}.meta.json"
|
||||
echo " 🗑️ Deleted: $(basename "$old_file")"
|
||||
done <<< "$TO_DELETE"
|
||||
else
|
||||
@@ -469,10 +571,11 @@ try:
|
||||
for obj in to_delete:
|
||||
old_key = obj['Key']
|
||||
client.delete_object(Bucket=bucket, Key=old_key)
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=old_key + '.sha256')
|
||||
except Exception:
|
||||
pass
|
||||
for suffix in ('.sha256', '.meta.json'):
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=old_key + suffix)
|
||||
except Exception:
|
||||
pass
|
||||
print(f' 🗑️ Deleted from R2: {old_key.replace("backups/", "")}')
|
||||
|
||||
sys.exit(0)
|
||||
@@ -495,6 +598,13 @@ PYEOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# --------------------------------------------------
|
||||
# Status metadata sidecar — after all 3 tiers are known
|
||||
# --------------------------------------------------
|
||||
echo ""
|
||||
echo "📝 Writing status metadata sidecar..."
|
||||
write_meta_sidecar
|
||||
|
||||
# --------------------------------------------------
|
||||
# Final summary + log
|
||||
# --------------------------------------------------
|
||||
|
||||
@@ -103,6 +103,38 @@ declare -A DB_USER_KEY=( [n8n]=DB_POSTGRESDB_USER [odoo]=POSTGRES_USER [mautic]=
|
||||
declare -A DB_PASS_KEY=( [n8n]=DB_POSTGRESDB_PASSWORD [odoo]=POSTGRES_PASSWORD [mautic]=MYSQL_ROOT_PASSWORD [nextcloud]=POSTGRES_PASSWORD [frappe]=MARIADB_ROOT_PASSWORD )
|
||||
declare -A DOMAIN=( [n8n]="n8nwf.nav.ovh" [odoo]="odooo.nav.ovh" [mautic]="mautics.nav.ovh" [nextcloud]="next.cloud.nav.ovh" [frappe]="erpnext.navitrends.ovh" )
|
||||
|
||||
# Dirs backup-k8s-apps.sh excludes from pvc-data.tar.gz for these two apps
|
||||
# (reproducible framework/vendor code, not user data — see matching
|
||||
# TAR_EXCLUDES table + comment there). They must be re-seeded here from the
|
||||
# app's own live image, NOT reconstructed by the container entrypoint:
|
||||
# - Mautic's image has no logic at all to repopulate node_modules/vendor
|
||||
# if missing — confirmed empty by reading /entrypoint.sh.
|
||||
# - Nextcloud's entrypoint only rsyncs from /usr/src/nextcloud when it
|
||||
# detects image_version > installed_version (via version.php). Since
|
||||
# version.php itself is NOT excluded (kept, "everything else"), a
|
||||
# restored version.php already matches the running image's version, so
|
||||
# that rsync path never fires — relying on it would silently leave
|
||||
# apps/core/dist/3rdparty/lib missing after restore.
|
||||
# Both images bake these dirs into their own filesystem layer independent
|
||||
# of any volume mount, so cp'ing from a loader pod running the same image
|
||||
# (PVC mounted elsewhere, so it doesn't shadow the image's own copy) is
|
||||
# reliable regardless of either app's startup logic.
|
||||
#
|
||||
# RECONSTRUCT_SRC is the path *inside the image* to copy from — NOT
|
||||
# necessarily pvc_path. Verified per app by running the actual image with no
|
||||
# volume mounted and checking where it bakes the dirs in:
|
||||
# - Mautic bakes node_modules/vendor directly at /var/www/html (its own
|
||||
# PVC mount path) — same path, source == destination parent.
|
||||
# - Nextcloud's image keeps its shipped source tree at /usr/src/nextcloud
|
||||
# (confirmed via `du -sh` in a bare nextcloud:32 pod), completely
|
||||
# separate from /var/www/html (the PVC mount, empty at that path in the
|
||||
# image). Assuming pvc_path here (mirroring Mautic's layout) was wrong
|
||||
# and failed loudly in testing — cp: cannot stat '/var/www/html/apps' —
|
||||
# caught by the reseed_failed gate above rather than silently booting
|
||||
# nextcloud broken.
|
||||
declare -A RECONSTRUCT_DIRS=( [mautic]="node_modules vendor" [nextcloud]="apps core dist 3rdparty lib" )
|
||||
declare -A RECONSTRUCT_SRC=( [mautic]="/var/www/html" [nextcloud]="/usr/src/nextcloud" )
|
||||
|
||||
ALL_APPS="n8n odoo mautic nextcloud frappe"
|
||||
LOADER_IMAGE="alpine:3.20"
|
||||
|
||||
@@ -168,7 +200,18 @@ for app in $ALL_APPS; do
|
||||
# restored here already exists in a same-cluster restore) ----
|
||||
if [ -f "$APP_DIR/secret.yaml" ]; then
|
||||
echo -n " 🔑 Applying Secret ... "
|
||||
kubectl apply -f "$APP_DIR/secret.yaml" &>/dev/null && echo "✅" || { echo "⚠️ FAILED"; app_ok=false; }
|
||||
# secret.yaml is a raw `kubectl get -o yaml` dump, which includes the
|
||||
# live resourceVersion/uid/creationTimestamp at capture time. Passing
|
||||
# a stale resourceVersion to `apply` trips optimistic-concurrency
|
||||
# control ("the object has been modified") on any restore where the
|
||||
# secret's real resourceVersion has since moved on — which is every
|
||||
# restore, since normal cluster activity (this backup pipeline's own
|
||||
# earlier applies included) bumps it constantly. Strip the volatile
|
||||
# fields before applying; they're server-assigned and never meant to
|
||||
# round-trip through a backup.
|
||||
sed -e '/^ resourceVersion:/d' -e '/^ uid:/d' -e '/^ creationTimestamp:/d' \
|
||||
"$APP_DIR/secret.yaml" > "$APP_DIR/secret.apply.yaml"
|
||||
kubectl apply -f "$APP_DIR/secret.apply.yaml" &>/dev/null && echo "✅" || { echo "⚠️ FAILED"; app_ok=false; }
|
||||
fi
|
||||
# NOTE: manifests.yaml (Deployment/Service/ConfigMap/Ingress specs) is
|
||||
# captured by backup-k8s-apps.sh but deliberately NOT re-applied here.
|
||||
@@ -216,9 +259,68 @@ for app in $ALL_APPS; do
|
||||
echo " ⏭️ No pvc-data.tar.gz in backup — skipping PVC restore"
|
||||
fi
|
||||
|
||||
# ---- 3b. Re-seed reproducible dirs excluded from backup (see
|
||||
# RECONSTRUCT_DIRS comment above) from the app's own live image,
|
||||
# never from the backup archive itself ----
|
||||
reseed_failed=false
|
||||
if [ -n "${RECONSTRUCT_DIRS[$app]:-}" ] && kubectl get pvc "$pvc_name" -n "$ns" &>/dev/null; then
|
||||
app_image=$(kubectl get deployment "$app_deploy" -n "$ns" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)
|
||||
if [ -n "$app_image" ]; then
|
||||
seed_loader="${app}-reseed-loader"
|
||||
echo -n " 🧩 Re-seeding [${RECONSTRUCT_DIRS[$app]}] from $app_image ... "
|
||||
# imagePullPolicy IfNotPresent: $app_image is read straight off the
|
||||
# currently-running Deployment, so it's already on this node —
|
||||
# forcing a re-pull (the ":latest"/floating-tag default) just adds
|
||||
# a slow, needless network fetch during every restore.
|
||||
kubectl run "$seed_loader" -n "$ns" --image="$app_image" --restart=Never \
|
||||
--overrides="{\"spec\":{\"containers\":[{\"name\":\"loader\",\"image\":\"${app_image}\",\"imagePullPolicy\":\"IfNotPresent\",\"command\":[\"sleep\",\"3600\"],\"volumeMounts\":[{\"name\":\"data\",\"mountPath\":\"/restore\"}]}],\"volumes\":[{\"name\":\"data\",\"persistentVolumeClaim\":{\"claimName\":\"${pvc_name}\"}}]}}" \
|
||||
&>/dev/null
|
||||
# 240s, not 60/90s like the alpine loader above: this pulls the
|
||||
# full app image (600MB+ for mautic/nextcloud) if it isn't cached
|
||||
# on this node yet — a 90s timeout was observed to fire mid-pull
|
||||
# in testing, silently leaving the app pod without its
|
||||
# reconstructed dirs after being scaled back up.
|
||||
if kubectl wait --for=condition=Ready "pod/$seed_loader" -n "$ns" --timeout=240s &>/dev/null; then
|
||||
seed_ok=true
|
||||
: > "$APP_DIR/reseed.err"
|
||||
seed_src="${RECONSTRUCT_SRC[$app]}"
|
||||
for _seed_dir in ${RECONSTRUCT_DIRS[$app]}; do
|
||||
kubectl exec -n "$ns" "$seed_loader" -- sh -c "rm -rf '/restore/$_seed_dir' && cp -a '${seed_src}/${_seed_dir}' '/restore/$_seed_dir'" \
|
||||
>>"$APP_DIR/reseed.err" 2>&1 || seed_ok=false
|
||||
done
|
||||
if $seed_ok; then
|
||||
echo "✅"
|
||||
rm -f "$APP_DIR/reseed.err"
|
||||
else
|
||||
echo "⚠️ FAILED (see $APP_DIR/reseed.err)"
|
||||
app_ok=false
|
||||
reseed_failed=true
|
||||
fi
|
||||
else
|
||||
echo "⚠️ loader pod not ready"
|
||||
app_ok=false
|
||||
reseed_failed=true
|
||||
fi
|
||||
kubectl delete pod "$seed_loader" -n "$ns" --wait=false &>/dev/null
|
||||
else
|
||||
echo " ⚠️ Could not determine $app image — skipping re-seed of [${RECONSTRUCT_DIRS[$app]}]"
|
||||
app_ok=false
|
||||
reseed_failed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 4. Scale app back up ----
|
||||
echo -n " ▶️ Scaling $app_deploy back to $prior_replicas ... "
|
||||
kubectl scale deployment "$app_deploy" -n "$ns" --replicas="$prior_replicas" &>/dev/null && echo "✅" || echo "⚠️ FAILED"
|
||||
# Never bring the app back with replicas>0 if a required re-seed (3b)
|
||||
# failed — for Mautic/Nextcloud that means booting with vendor/ or
|
||||
# core/lib missing, which is a hard crash/broken-app state, not a
|
||||
# degraded one. Left at 0 so the operator fixes it and scales up
|
||||
# manually rather than the restore silently serving a broken app.
|
||||
if $reseed_failed; then
|
||||
echo " ⛔ Skipping scale-up — required re-seed failed (app left at 0 replicas, see above)"
|
||||
else
|
||||
echo -n " ▶️ Scaling $app_deploy back to $prior_replicas ... "
|
||||
kubectl scale deployment "$app_deploy" -n "$ns" --replicas="$prior_replicas" &>/dev/null && echo "✅" || echo "⚠️ FAILED"
|
||||
fi
|
||||
|
||||
# ---- 5. DB restore (SQL-level, never raw datadir copy) ----
|
||||
if [ -f "$APP_DIR/db-dump.sql.gz" ]; then
|
||||
|
||||
@@ -11,12 +11,14 @@ from datetime import datetime, timezone
|
||||
from config import (
|
||||
MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER,
|
||||
VM_HOST, VM_PORT, VM_KEY, VM_USER,
|
||||
MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER,
|
||||
MAIN_SERVER_KEY, MAIN_SERVER_USER,
|
||||
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
|
||||
)
|
||||
from modules.auth import login_required
|
||||
from modules.backups import (
|
||||
get_containers, get_all_root_containers,
|
||||
get_local_backups, get_vm_backups,
|
||||
get_local_backups_with_status, get_vm_backups_with_status,
|
||||
get_all_stats, get_system_info,
|
||||
get_rootless_user_containers_remote,
|
||||
container_action, get_container_status,
|
||||
@@ -169,8 +171,8 @@ def cluster_page():
|
||||
def backups_page():
|
||||
return render_template(
|
||||
'pages/backups.html',
|
||||
backups=get_local_backups(),
|
||||
vm_backups=get_vm_backups(),
|
||||
backups=get_local_backups_with_status(),
|
||||
vm_backups=get_vm_backups_with_status(),
|
||||
main_server=MAIN_SERVER_IP,
|
||||
active_page='backups',
|
||||
page_title='Backup Management',
|
||||
@@ -191,6 +193,14 @@ def restore_page():
|
||||
vm_backups=get_vm_backups(),
|
||||
restore_prefill=prefill,
|
||||
main_server=MAIN_SERVER_IP,
|
||||
running_on_main_server=RUNNING_ON_MAIN_SERVER,
|
||||
# Only meaningful when running as the standby: "restore on this
|
||||
# server" is nonsensical there (no local cluster/kubectl at all),
|
||||
# so pre-fill the remote-target fields with the tunnel back to the
|
||||
# real main server instead of leaving the user to find these values.
|
||||
tunnel_remote_ip=MAIN_SERVER_SSH_HOST,
|
||||
tunnel_remote_port=MAIN_SERVER_SSH_PORT,
|
||||
tunnel_ssh_key=MAIN_SERVER_KEY,
|
||||
active_page='restore',
|
||||
page_title='Restore',
|
||||
page_subtitle='backup → target'
|
||||
@@ -376,7 +386,7 @@ def api_container_status(name):
|
||||
@app.route('/api/backups')
|
||||
@login_required
|
||||
def api_backups():
|
||||
return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()})
|
||||
return jsonify({'local': get_local_backups_with_status(), 'vm': get_vm_backups_with_status()})
|
||||
|
||||
|
||||
@app.route('/api/backups/log')
|
||||
@@ -694,9 +704,9 @@ def api_r2_upload():
|
||||
local_path = f"/tmp/{backup_file}"
|
||||
if not os.path.exists(local_path):
|
||||
pull_cmd = (
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} "
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
|
||||
f"{local_path}"
|
||||
)
|
||||
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||
@@ -798,9 +808,9 @@ def restore_start():
|
||||
backup_path = f"/tmp/{backup_file}"
|
||||
if not os.path.exists(backup_path):
|
||||
pull_cmd = (
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} "
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
|
||||
f"{backup_path}"
|
||||
)
|
||||
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
|
||||
@@ -834,6 +844,12 @@ def restore_start():
|
||||
return jsonify({'error': f'restore-k8s-apps.sh not found at {restore_script_local}'}), 500
|
||||
|
||||
if target == 'local':
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
return jsonify({
|
||||
'error': 'This is the standby — there is no local cluster here to restore '
|
||||
'into. Use "External Machine" to restore onto the real main server '
|
||||
'over the tunnel instead.'
|
||||
}), 400
|
||||
hostname = os.uname().nodename
|
||||
os.makedirs('/root/tmp', exist_ok=True)
|
||||
session_dir = f"/root/tmp/restore-session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -11,7 +11,8 @@ import socket
|
||||
import psutil
|
||||
from config import (
|
||||
RUNNING_ON_MAIN_SERVER,
|
||||
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT,
|
||||
MAIN_SERVER_USER, MAIN_SERVER_KEY,
|
||||
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
|
||||
VM_HOST, VM_PORT, VM_KEY, VM_USER,
|
||||
)
|
||||
|
||||
@@ -43,10 +44,10 @@ def _ssh_main(remote_cmd, timeout=30):
|
||||
else:
|
||||
escaped = remote_cmd.replace("'", "'\\''")
|
||||
ssh = (
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} "
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
||||
f"-o BatchMode=yes "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}"
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}"
|
||||
)
|
||||
return _run(f"{ssh} '{escaped}'", timeout=timeout)
|
||||
|
||||
@@ -127,6 +128,127 @@ def get_vm_backups():
|
||||
return vm_backups
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# BACKUP STATUS DOTS (green/yellow/red/unknown)
|
||||
#
|
||||
# Reads the .meta.json sidecar backup-k8s-apps.sh writes alongside each
|
||||
# archive (never decompresses the archive itself). Legacy myapps-backup-*
|
||||
# archives and any k8s backup made before this feature shipped have no
|
||||
# sidecar and show as 'unknown' — there's no reliable way to reconstruct
|
||||
# per-step error info after the fact, and no backfill is attempted.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
ROLLING_WINDOW = 5
|
||||
SIZE_DEVIATION_THRESHOLD = 0.40
|
||||
|
||||
|
||||
def _load_local_sidecars(local_dir='/root/backups'):
|
||||
"""All .meta.json sidecars in a directory, newest first. Used both to
|
||||
look up one backup's own metadata and as the history pool for its
|
||||
rolling-average comparison."""
|
||||
sidecars = {}
|
||||
for path in glob.glob(f'{local_dir}/*.tar.gz.meta.json'):
|
||||
try:
|
||||
with open(path) as f:
|
||||
m = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
backup_file = os.path.basename(path)[:-len('.meta.json')]
|
||||
m['_backup_file'] = backup_file
|
||||
sidecars[backup_file] = m
|
||||
return sidecars
|
||||
|
||||
|
||||
def _parse_vm_sidecars(cat_output):
|
||||
"""Parse the batched `echo marker + cat` SSH output from _fetch_vm_sidecars
|
||||
into the same {backup_file: meta} shape as _load_local_sidecars."""
|
||||
sidecars = {}
|
||||
if not cat_output:
|
||||
return sidecars
|
||||
for block in cat_output.split('===META:')[1:]:
|
||||
try:
|
||||
fname_line, _, rest = block.partition('===\n')
|
||||
backup_file = fname_line.strip()[:-len('.meta.json')]
|
||||
m = json.loads(rest)
|
||||
m['_backup_file'] = backup_file
|
||||
sidecars[backup_file] = m
|
||||
except Exception:
|
||||
continue
|
||||
return sidecars
|
||||
|
||||
|
||||
def _fetch_vm_sidecars():
|
||||
local_dir = '/backups/cloudproject'
|
||||
if os.path.isdir(local_dir):
|
||||
return _load_local_sidecars(local_dir)
|
||||
cmd = (
|
||||
f"ssh -i {VM_KEY} -p {VM_PORT} -o StrictHostKeyChecking=no "
|
||||
f"-o ConnectTimeout=10 -o BatchMode=yes {VM_USER}@{VM_HOST} "
|
||||
f"'for f in /backups/cloudproject/*.tar.gz.meta.json; do "
|
||||
f"[ -f \"$f\" ] && echo \"===META:$(basename \"$f\")===\" && cat \"$f\"; "
|
||||
f"done 2>/dev/null'"
|
||||
)
|
||||
stdout, _ = _run(cmd, timeout=25)
|
||||
return _parse_vm_sidecars(stdout)
|
||||
|
||||
|
||||
def _compute_backup_status(meta, all_sidecars):
|
||||
"""all_sidecars: dict of {backup_file: meta} — the pool this entry's
|
||||
rolling average is computed from (same directory/tier it came from)."""
|
||||
tiers = meta.get('tiers', {})
|
||||
if meta.get('errors') or any(str(v).startswith('failed') for v in tiers.values()):
|
||||
return 'red'
|
||||
|
||||
apps_key = meta.get('apps_key', '')
|
||||
size_bytes = meta.get('size_bytes', 0)
|
||||
created_at = meta.get('created_at', '')
|
||||
|
||||
# Same app-combination, strictly older than this backup, most recent
|
||||
# first — a fresh, self-calibrating baseline per app-set rather than
|
||||
# one hardcoded global threshold (a 1-app and a 5-app backup have very
|
||||
# different normal sizes).
|
||||
history = sorted(
|
||||
(m for m in all_sidecars.values()
|
||||
if m.get('apps_key') == apps_key and m.get('created_at', '') < created_at),
|
||||
key=lambda m: m.get('created_at', ''),
|
||||
reverse=True,
|
||||
)[:ROLLING_WINDOW]
|
||||
|
||||
if not history:
|
||||
return 'green' # first backup of this app-combination — nothing to compare against
|
||||
|
||||
avg = sum(m.get('size_bytes', 0) for m in history) / len(history)
|
||||
if avg <= 0:
|
||||
return 'green'
|
||||
|
||||
deviation = abs(size_bytes - avg) / avg
|
||||
return 'yellow' if deviation > SIZE_DEVIATION_THRESHOLD else 'green'
|
||||
|
||||
|
||||
def _with_status(names, sidecars):
|
||||
result = []
|
||||
for name in names:
|
||||
meta = sidecars.get(name)
|
||||
if meta is None:
|
||||
result.append({'name': name, 'status': 'unknown', 'size_human': None, 'apps': None})
|
||||
else:
|
||||
result.append({
|
||||
'name': name,
|
||||
'status': _compute_backup_status(meta, sidecars),
|
||||
'size_human': _human_bytes(meta.get('size_bytes', 0)),
|
||||
'apps': meta.get('apps'),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_local_backups_with_status():
|
||||
return _with_status(get_local_backups(), _load_local_sidecars())
|
||||
|
||||
|
||||
def get_vm_backups_with_status():
|
||||
return _with_status(get_vm_backups(), _fetch_vm_sidecars())
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# BACKUP HEALTH AUDIT
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -154,9 +276,9 @@ def _resolve_archive_path(backup_file, source, add):
|
||||
tmp_path = f"/tmp/audit_{backup_file}"
|
||||
if not os.path.exists(tmp_path):
|
||||
pull_cmd = (
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} "
|
||||
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
|
||||
f"{tmp_path}"
|
||||
)
|
||||
_out, err = _run(pull_cmd, timeout=120)
|
||||
@@ -524,11 +646,16 @@ def delete_backup(backup_file, source='local'):
|
||||
if not os.path.exists(archive_path):
|
||||
return False, f'File not found: {archive_path}'
|
||||
os.remove(archive_path)
|
||||
sha = archive_path + '.sha256'
|
||||
if os.path.exists(sha): os.remove(sha)
|
||||
for suffix in ('.sha256', '.meta.json'):
|
||||
sidecar = archive_path + suffix
|
||||
if os.path.exists(sidecar): os.remove(sidecar)
|
||||
return True, f'Deleted {backup_file} from main server'
|
||||
else:
|
||||
cmd = f"rm -f /root/backups/{backup_file} /root/backups/{backup_file}.sha256"
|
||||
cmd = (
|
||||
f"rm -f /root/backups/{backup_file} "
|
||||
f"/root/backups/{backup_file}.sha256 "
|
||||
f"/root/backups/{backup_file}.meta.json"
|
||||
)
|
||||
out, err = _ssh_main(cmd)
|
||||
if err and 'No such file' not in err:
|
||||
return False, f'Remote delete error: {err}'
|
||||
@@ -540,8 +667,9 @@ def delete_backup(backup_file, source='local'):
|
||||
if not os.path.exists(archive_path):
|
||||
return False, f'File not found: {archive_path}'
|
||||
os.remove(archive_path)
|
||||
sha = archive_path + '.sha256'
|
||||
if os.path.exists(sha): os.remove(sha)
|
||||
for suffix in ('.sha256', '.meta.json'):
|
||||
sidecar = archive_path + suffix
|
||||
if os.path.exists(sidecar): os.remove(sidecar)
|
||||
return True, f'Deleted {backup_file} from VM'
|
||||
else:
|
||||
cmd = (
|
||||
@@ -550,7 +678,8 @@ def delete_backup(backup_file, source='local'):
|
||||
f"-o BatchMode=yes "
|
||||
f"{VM_USER}@{VM_HOST} "
|
||||
f"'rm -f /backups/cloudproject/{backup_file} "
|
||||
f"/backups/cloudproject/{backup_file}.sha256'"
|
||||
f"/backups/cloudproject/{backup_file}.sha256 "
|
||||
f"/backups/cloudproject/{backup_file}.meta.json'"
|
||||
)
|
||||
out, err = _run(cmd, timeout=30)
|
||||
if err and 'No such file' not in err:
|
||||
@@ -766,8 +895,65 @@ def get_all_stats():
|
||||
# SYSTEM INFO (FIXED WITH PSUTIL)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_main_server_system_info_remote():
|
||||
"""get_system_info()'s equivalent for the standby: the main server's bare
|
||||
host has no psutil (system python3, not this app's container image), so
|
||||
this uses plain coreutils/procps instead — reusing _ssh_main means this
|
||||
genuinely queries the real main server over the tunnel, not the VM
|
||||
itself (the previous unconditional psutil.* calls silently reported the
|
||||
VM's own stats mislabeled as "system info" whenever running as standby)."""
|
||||
fallback = {
|
||||
'cpu_pct': '0', 'memory': 'N/A', 'mem_pct': '0', 'disk': 'N/A',
|
||||
'disk_pct': '0', 'load': 'N/A', 'uptime': 'N/A', 'docker_v': 'N/A',
|
||||
'hostname': 'unreachable',
|
||||
}
|
||||
remote_cmd = (
|
||||
"hostname; "
|
||||
"vmstat 1 2 | tail -1 | awk '{print 100-$15}'; "
|
||||
"free -m | awk 'NR==2{printf \"%d %d %d\\n\", $3, $2, $3*100/$2}'; "
|
||||
"df -BM --output=used,size,pcent / | tail -1 | tr -d 'M%'; "
|
||||
"awk '{printf \"%.2f %.2f %.2f\\n\", $1, $2, $3}' /proc/loadavg; "
|
||||
"awk '{print $1}' /proc/uptime; "
|
||||
"docker --version 2>/dev/null | awk '{print $3}' | tr -d ','"
|
||||
)
|
||||
out, err = _ssh_main(remote_cmd, timeout=15)
|
||||
lines = out.split('\n') if out else []
|
||||
if len(lines) < 7:
|
||||
return fallback
|
||||
try:
|
||||
hostname = lines[0].strip()
|
||||
cpu_pct = lines[1].strip()
|
||||
mem_used_mb, mem_total_mb, mem_pct = lines[2].split()
|
||||
disk_used_mb, disk_total_mb, disk_pct = lines[3].split()
|
||||
load = lines[4].strip()
|
||||
uptime_s = float(lines[5].strip())
|
||||
docker_v = lines[6].strip() or 'N/A'
|
||||
|
||||
days = int(uptime_s // 86400)
|
||||
hours = int((uptime_s % 86400) // 3600)
|
||||
minutes = int((uptime_s % 3600) // 60)
|
||||
uptime = f"{days}d {hours}h {minutes}m" if days > 0 else f"{hours}h {minutes}m"
|
||||
|
||||
return {
|
||||
'cpu_pct': cpu_pct,
|
||||
'memory': f"{int(mem_used_mb) / 1024:.1f}G/{int(mem_total_mb) / 1024:.1f}G",
|
||||
'mem_pct': mem_pct,
|
||||
'disk': f"{int(disk_used_mb) / 1024:.0f}G/{int(disk_total_mb) / 1024:.0f}G",
|
||||
'disk_pct': disk_pct,
|
||||
'load': load,
|
||||
'uptime': uptime,
|
||||
'docker_v': docker_v,
|
||||
'hostname': hostname,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[backups] remote system info parse error: {e} (raw: {out!r}, err: {err!r})")
|
||||
return fallback
|
||||
|
||||
|
||||
def get_system_info():
|
||||
"""Get system information using psutil for reliable metrics inside container"""
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
return _get_main_server_system_info_remote()
|
||||
|
||||
info = {
|
||||
'cpu_pct': '0',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import subprocess
|
||||
from config import RUNNING_ON_MAIN_SERVER, MAIN_SERVER_IP
|
||||
from config import RUNNING_ON_MAIN_SERVER, MAIN_SERVER_KEY, MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT
|
||||
|
||||
def run_command(command):
|
||||
"""Run command on main server (directly or via SSH)"""
|
||||
@@ -14,10 +14,12 @@ def run_command(command):
|
||||
else:
|
||||
ssh_cmd = [
|
||||
"ssh",
|
||||
"-i", MAIN_SERVER_KEY,
|
||||
"-p", MAIN_SERVER_SSH_PORT,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "BatchMode=yes",
|
||||
f"root@{MAIN_SERVER_IP}",
|
||||
f"root@{MAIN_SERVER_SSH_HOST}",
|
||||
command
|
||||
]
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
# deployments.apps — no secrets, no write, no exec/log), bound per-namespace
|
||||
# across the 8 app namespaces below. There is no cluster-wide RoleBinding,
|
||||
# so every list call below is scoped to one namespace at a time.
|
||||
#
|
||||
# On the standby (RUNNING_ON_MAIN_SERVER False), there is no in-cluster
|
||||
# ServiceAccount to load at all — load_incluster_config() always fails
|
||||
# there, by design, even if the standby itself later runs as a pod on a
|
||||
# *local* k3s on that VM (that cluster isn't this one; in-cluster config
|
||||
# would only ever see the wrong, essentially-empty cluster). Every function
|
||||
# below instead SSHes to the main server (over main-to-vm-tunnel.service)
|
||||
# and runs `kubectl` there directly, where real cluster-admin access
|
||||
# already exists — same real-vs-display separation as MAIN_SERVER_SSH_HOST
|
||||
# in config.py.
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
@@ -13,6 +25,11 @@ try:
|
||||
except ImportError:
|
||||
_K8S_IMPORT_OK = False
|
||||
|
||||
from config import (
|
||||
RUNNING_ON_MAIN_SERVER, MAIN_SERVER_USER, MAIN_SERVER_KEY,
|
||||
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
|
||||
)
|
||||
|
||||
NAMESPACES = [
|
||||
'n8n', 'odoo', 'mautic', 'erpnext', 'nextcloud',
|
||||
'jenkins-agents', 'jenkins', 'management-platform',
|
||||
@@ -25,6 +42,78 @@ _networking_v1 = None
|
||||
K8S_AVAILABLE = False
|
||||
|
||||
|
||||
def _ssh_kubectl(args):
|
||||
"""Run `kubectl <args> -o json` on the main server over the reverse
|
||||
tunnel. Returns the parsed JSON (dict) or None on any failure —
|
||||
callers must treat None/empty the same as the in-cluster client
|
||||
returning nothing, never as a hard error."""
|
||||
remote_cmd = "kubectl " + " ".join(args) + " -o json"
|
||||
ssh_cmd = (
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST} '{remote_cmd}'"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(ssh_cmd, shell=True, capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return json.loads(r.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ssh_kubectl_raw(path):
|
||||
"""Same as _ssh_kubectl but for `kubectl get --raw <path>` (used for
|
||||
the metrics-server API, which isn't a normal `get <resource>` call)."""
|
||||
ssh_cmd = (
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST} 'kubectl get --raw {path}'"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(ssh_cmd, shell=True, capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return json.loads(r.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_k8s_ts(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pod_health_raw(pod):
|
||||
statuses = (pod.get('status') or {}).get('containerStatuses') or []
|
||||
for cs in statuses:
|
||||
waiting = (cs.get('state') or {}).get('waiting')
|
||||
if waiting and waiting.get('reason') in _CRASH_REASONS:
|
||||
return 'failed'
|
||||
phase = (pod.get('status') or {}).get('phase') or 'Unknown'
|
||||
if phase == 'Failed':
|
||||
return 'failed'
|
||||
if phase == 'Running':
|
||||
all_ready = all(cs.get('ready') for cs in statuses) if statuses else True
|
||||
return 'healthy' if all_ready else 'degraded'
|
||||
return 'degraded'
|
||||
|
||||
|
||||
def _ready_count_raw(pod):
|
||||
statuses = (pod.get('status') or {}).get('containerStatuses') or []
|
||||
ready = sum(1 for cs in statuses if cs.get('ready'))
|
||||
return f'{ready}/{len(statuses)}'
|
||||
|
||||
|
||||
def _restart_count_raw(pod):
|
||||
statuses = (pod.get('status') or {}).get('containerStatuses') or []
|
||||
return sum(cs.get('restartCount', 0) for cs in statuses)
|
||||
|
||||
|
||||
def _init_client():
|
||||
global _core_v1, _apps_v1, _custom_objects, _networking_v1, K8S_AVAILABLE
|
||||
if _core_v1 is not None or not _K8S_IMPORT_OK:
|
||||
@@ -135,6 +224,24 @@ def _parse_mem_mib(v):
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_pods():
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
pods = []
|
||||
for ns in NAMESPACES:
|
||||
data = _ssh_kubectl(['get', 'pods', '-n', ns])
|
||||
if not data:
|
||||
continue
|
||||
for pod in data.get('items', []):
|
||||
pods.append({
|
||||
'name': pod['metadata']['name'],
|
||||
'namespace': ns,
|
||||
'phase': (pod.get('status') or {}).get('phase') or 'Unknown',
|
||||
'health': _pod_health_raw(pod),
|
||||
'ready': _ready_count_raw(pod),
|
||||
'restarts': _restart_count_raw(pod),
|
||||
'age': _age_str(_parse_k8s_ts(pod['metadata'].get('creationTimestamp'))),
|
||||
})
|
||||
return pods
|
||||
|
||||
_init_client()
|
||||
pods = []
|
||||
if not K8S_AVAILABLE:
|
||||
@@ -159,6 +266,25 @@ def list_pods():
|
||||
|
||||
|
||||
def list_deployments():
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
deployments = []
|
||||
for ns in NAMESPACES:
|
||||
data = _ssh_kubectl(['get', 'deployments', '-n', ns])
|
||||
if not data:
|
||||
continue
|
||||
for dep in data.get('items', []):
|
||||
desired = (dep.get('spec') or {}).get('replicas') or 0
|
||||
ready = (dep.get('status') or {}).get('readyReplicas') or 0
|
||||
deployments.append({
|
||||
'name': dep['metadata']['name'],
|
||||
'namespace': ns,
|
||||
'ready': ready,
|
||||
'desired': desired,
|
||||
'health': _deploy_health(desired, ready),
|
||||
'age': _age_str(_parse_k8s_ts(dep['metadata'].get('creationTimestamp'))),
|
||||
})
|
||||
return deployments
|
||||
|
||||
_init_client()
|
||||
deployments = []
|
||||
if not K8S_AVAILABLE:
|
||||
@@ -187,6 +313,19 @@ def get_ingress_info(name, namespace):
|
||||
"""Real domain/TLS for one Ingress, or None if unavailable/not found.
|
||||
Used by modules/sites.py instead of the old hardcoded Docker-era
|
||||
domain fields — reflects actual live k8s Ingress state."""
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
data = _ssh_kubectl(['get', 'ingress', name, '-n', namespace])
|
||||
if not data:
|
||||
return None
|
||||
host = None
|
||||
rules = (data.get('spec') or {}).get('rules') or []
|
||||
for rule in rules:
|
||||
if rule.get('host'):
|
||||
host = rule['host']
|
||||
break
|
||||
tls = bool((data.get('spec') or {}).get('tls'))
|
||||
return {'host': host, 'tls': tls}
|
||||
|
||||
_init_client()
|
||||
if not K8S_AVAILABLE:
|
||||
return None
|
||||
@@ -207,6 +346,25 @@ def get_ingress_info(name, namespace):
|
||||
def list_deployments_for_namespace(ns):
|
||||
"""Targeted single-namespace deployment list (vs. list_deployments()'s
|
||||
fixed NAMESPACES sweep) — used by modules/sites.py per app."""
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
data = _ssh_kubectl(['get', 'deployments', '-n', ns])
|
||||
if not data:
|
||||
return []
|
||||
out = []
|
||||
for dep in data.get('items', []):
|
||||
desired = (dep.get('spec') or {}).get('replicas') or 0
|
||||
ready = (dep.get('status') or {}).get('readyReplicas') or 0
|
||||
containers = (((dep.get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or []
|
||||
image = containers[0]['image'] if containers else '—'
|
||||
out.append({
|
||||
'name': dep['metadata']['name'],
|
||||
'desired': desired,
|
||||
'ready': ready,
|
||||
'health': _deploy_health(desired, ready),
|
||||
'image': image,
|
||||
})
|
||||
return out
|
||||
|
||||
_init_client()
|
||||
if not K8S_AVAILABLE:
|
||||
return []
|
||||
@@ -235,6 +393,20 @@ def get_pod_metrics():
|
||||
"""Best-effort CPU/mem per pod via metrics-server. Returns {} if it's not
|
||||
installed or unreachable — callers must treat an empty dict as 'no data',
|
||||
never as an error."""
|
||||
if not RUNNING_ON_MAIN_SERVER:
|
||||
metrics = {}
|
||||
for ns in NAMESPACES:
|
||||
resp = _ssh_kubectl_raw(f'/apis/metrics.k8s.io/v1beta1/namespaces/{ns}/pods')
|
||||
if not resp:
|
||||
continue
|
||||
for item in resp.get('items', []):
|
||||
name = item.get('metadata', {}).get('name')
|
||||
containers = item.get('containers', [])
|
||||
cpu = sum(_parse_cpu(c.get('usage', {}).get('cpu')) for c in containers)
|
||||
mem = sum(_parse_mem_mib(c.get('usage', {}).get('memory')) for c in containers)
|
||||
metrics[f'{ns}/{name}'] = {'cpu_millicores': round(cpu), 'mem_mib': round(mem)}
|
||||
return metrics
|
||||
|
||||
_init_client()
|
||||
metrics = {}
|
||||
if not K8S_AVAILABLE:
|
||||
@@ -283,8 +455,13 @@ def get_cluster_overview():
|
||||
'deployments_healthy': sum(1 for d in deployments if d['health'] == 'healthy'),
|
||||
}
|
||||
|
||||
# K8S_AVAILABLE only reflects the in-cluster client (main server); on
|
||||
# the standby, "available" instead means the SSH+kubectl fetch above
|
||||
# actually returned something.
|
||||
available = K8S_AVAILABLE if RUNNING_ON_MAIN_SERVER else bool(pods or deployments)
|
||||
|
||||
return {
|
||||
'available': K8S_AVAILABLE,
|
||||
'available': available,
|
||||
'namespaces': by_namespace,
|
||||
'summary': summary,
|
||||
'metrics_available': bool(metrics),
|
||||
|
||||
@@ -7,7 +7,8 @@ import json
|
||||
|
||||
from config import (
|
||||
RUNNING_ON_MAIN_SERVER,
|
||||
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT,
|
||||
MAIN_SERVER_USER, MAIN_SERVER_KEY,
|
||||
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,10 +34,10 @@ def _ssh_main(remote_cmd, timeout=30):
|
||||
# Escape single quotes in remote_cmd for safe shell wrapping
|
||||
escaped = remote_cmd.replace("'", "'\\''")
|
||||
ssh = (
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} "
|
||||
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
|
||||
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
||||
f"-o BatchMode=yes "
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}"
|
||||
f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}"
|
||||
)
|
||||
return _run(f"{ssh} '{escaped}'", timeout=timeout)
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ a.sidebar-brand-link {
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.backup-item-main { min-width: 0; padding-bottom: 12px; }
|
||||
.backup-item-main { min-width: 0; padding-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.backup-item:not(:has(.backup-actions)) .backup-item-main { padding-bottom: 0; }
|
||||
|
||||
.backup-name {
|
||||
@@ -894,6 +894,18 @@ a.sidebar-brand-link {
|
||||
}
|
||||
.backup-name i { margin-right: 6px; opacity: 0.85; }
|
||||
|
||||
/* Backup status dot — green/yellow/red from the .meta.json sidecar
|
||||
backup-k8s-apps.sh writes; gray for backups with no sidecar (legacy
|
||||
archives, or anything made before this feature shipped). */
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
border: 1px solid rgba(0,0,0,0.15);
|
||||
}
|
||||
.status-dot-green { background: var(--green); box-shadow: 0 0 6px rgba(45,212,122,0.5); }
|
||||
.status-dot-yellow { background: var(--yellow); box-shadow: 0 0 6px rgba(245,185,66,0.5); }
|
||||
.status-dot-red { background: var(--red); box-shadow: 0 0 6px rgba(240,82,82,0.5); }
|
||||
.status-dot-unknown { background: var(--text3); box-shadow: none; }
|
||||
|
||||
.backup-meta {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -170,7 +170,10 @@ async function refreshSystemMetrics() {
|
||||
if (el('g-disk')) el('g-disk').style.width = Math.min(parseFloat(d.disk_pct) || 0, 100) + '%';
|
||||
setText('uptime-chip', d.uptime);
|
||||
if (el('settings-uptime')) el('settings-uptime').value = d.uptime;
|
||||
if (d.hostname && el('this-server-desc')) el('this-server-desc').textContent = d.hostname;
|
||||
const localTargetRadio = document.querySelector('input[name="restore_target"][value="local"]');
|
||||
if (d.hostname && el('this-server-desc') && !(localTargetRadio && localTargetRadio.disabled)) {
|
||||
el('this-server-desc').textContent = d.hostname;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -377,8 +380,8 @@ async function refreshBackupsList() {
|
||||
renderBackupList(d.vm, 'vm-backup-list', 'vm');
|
||||
if (el('stat-local-bk')) el('stat-local-bk').textContent = d.local.length;
|
||||
if (el('stat-vm-bk')) el('stat-vm-bk').textContent = d.vm.length;
|
||||
if (el('local-options')) el('local-options').innerHTML = d.local.length ? d.local.map((b) => `<option value="${b}" data-source="local">${b}</option>`).join('') : '<option disabled>No local backups</option>';
|
||||
if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => `<option value="${b}" data-source="vm">${b}</option>`).join('') : '<option disabled>No VM backups</option>';
|
||||
if (el('local-options')) el('local-options').innerHTML = d.local.length ? d.local.map((b) => `<option value="${b.name}" data-source="local">${b.name}</option>`).join('') : '<option disabled>No local backups</option>';
|
||||
if (el('vm-options')) el('vm-options').innerHTML = d.vm.length ? d.vm.map((b) => `<option value="${b.name}" data-source="vm">${b.name}</option>`).join('') : '<option disabled>No VM backups</option>';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -394,7 +397,13 @@ function renderBackupList(items, id, source) {
|
||||
return;
|
||||
}
|
||||
node.innerHTML = items.map((b) => {
|
||||
const safe = jsStr(b);
|
||||
const name = b.name;
|
||||
const safe = jsStr(name);
|
||||
const status = b.status || 'unknown';
|
||||
const titleParts = [status];
|
||||
if (b.apps && b.apps.length) titleParts.push(`apps: ${b.apps.join(', ')}`);
|
||||
if (b.size_human) titleParts.push(b.size_human);
|
||||
const title = escapeHtml(titleParts.join(' · '));
|
||||
const uploadBtn = source === 'local' && typeof quickUploadR2 === 'function'
|
||||
? `<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('${safe}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>`
|
||||
: '';
|
||||
@@ -402,9 +411,10 @@ function renderBackupList(items, id, source) {
|
||||
? `<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>`
|
||||
: '';
|
||||
return `
|
||||
<div class="backup-item" id="bk-item-${source}-${b.replace(/[^a-z0-9]/gi, '_')}">
|
||||
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
||||
<div class="backup-item-main">
|
||||
<span class="backup-name">${escapeHtml(b)}</span>
|
||||
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
||||
<span class="backup-name">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
${detailsBtn}
|
||||
|
||||
@@ -286,14 +286,15 @@
|
||||
{% for b in backups %}
|
||||
<div class="backup-item">
|
||||
<div class="backup-item-main">
|
||||
<span class="backup-name">{{ b }}</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>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('local','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b }}')">↩ Restore</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
|
||||
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b }}',this)"><i class="fas fa-trash"></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>
|
||||
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('local','{{ b.name }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('local','{{ b.name }}')">↩ Restore</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickUploadR2('{{ b.name }}',this)" title="Upload to R2"><i class="fas fa-cloud-upload-alt"></i></button>
|
||||
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('local','{{ b.name }}',this)"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No backups</div>{% endfor %}
|
||||
@@ -310,13 +311,14 @@
|
||||
{% for b in vm_backups %}
|
||||
<div class="backup-item">
|
||||
<div class="backup-item-main">
|
||||
<span class="backup-name">{{ b }}</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>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="btn btn-ghost btn-sm" style="color:var(--text3);" onclick="showBackupDetails('vm','{{ b }}',this)" title="Quick details"><i class="fas fa-circle-info"></i></button>
|
||||
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b }}')">↩ Restore</button>
|
||||
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b }}',this)"><i class="fas fa-trash"></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>
|
||||
<button class="btn btn-ghost btn-sm btn-audit" onclick="auditBackup('vm','{{ b.name }}',this)"><i class="fas fa-shield-check"></i> Audit</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="quickRestore('vm','{{ b.name }}')">↩ Restore</button>
|
||||
<button class="btn btn-ghost btn-sm btn-delete-backup" onclick="deleteBackup('vm','{{ b.name }}',this)"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}<div class="empty-state"><i class="fas fa-inbox"></i>No VM backups</div>{% endfor %}
|
||||
|
||||
@@ -63,26 +63,37 @@
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
|
||||
{% if not running_on_main_server %}
|
||||
<div style="margin-bottom:12px;padding:10px 14px;background:rgba(245,158,11,0.08);
|
||||
border:1px solid rgba(245,158,11,0.25);border-radius:8px;font-size:12px;color:var(--yellow);">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
Running as the standby — there's no local cluster here to restore into.
|
||||
"External Machine" is pre-filled to reach the real main server over the standby's
|
||||
SSH tunnel; only change these if you actually mean a different target.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="radio-group">
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="restore_target" value="local" checked onchange="toggleRemoteFields()">
|
||||
<div class="radio-body"><span class="radio-icon">🎯</span><div><div class="radio-label">Restore on This Server</div><div class="radio-desc" id="this-server-desc">Loading hostname…</div></div></div>
|
||||
<label class="radio-card{% if not running_on_main_server %} disabled{% endif %}">
|
||||
<input type="radio" name="restore_target" value="local"
|
||||
{% if running_on_main_server %}checked{% else %}disabled{% endif %}
|
||||
onchange="toggleRemoteFields()">
|
||||
<div class="radio-body"><span class="radio-icon">🎯</span><div><div class="radio-label">Restore on This Server</div><div class="radio-desc" id="this-server-desc">{% if running_on_main_server %}Loading hostname…{% else %}Not available on the standby{% endif %}</div></div></div>
|
||||
</label>
|
||||
<label class="radio-card">
|
||||
<input type="radio" name="restore_target" value="remote" onchange="toggleRemoteFields()">
|
||||
<input type="radio" name="restore_target" value="remote" {% if not running_on_main_server %}checked{% endif %} onchange="toggleRemoteFields()">
|
||||
<div class="radio-body"><span class="radio-icon">📡</span><div><div class="radio-label">External Machine</div><div class="radio-desc">via SSH — any IP</div></div></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="remote-fields" style="display:none; margin-top:16px; max-width:560px;">
|
||||
<div id="remote-fields" style="display:{% if running_on_main_server %}none{% else %}block{% endif %}; margin-top:16px; max-width:560px;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">TARGET IP</label>
|
||||
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.x.x or IP">
|
||||
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.x.x or IP" value="{{ tunnel_remote_ip if not running_on_main_server else '' }}">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:100px;">
|
||||
<label class="form-label">SSH PORT</label>
|
||||
<input type="text" id="remote-port" class="form-input" value="22">
|
||||
<input type="text" id="remote-port" class="form-input" value="{{ tunnel_remote_port if not running_on_main_server else '22' }}">
|
||||
</div>
|
||||
<div class="form-group" style="max-width:120px;">
|
||||
<label class="form-label">SSH USER</label>
|
||||
|
||||
127
scripts/sync-standby-platform.sh
Executable file
127
scripts/sync-standby-platform.sh
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# sync-standby-platform.sh
|
||||
# Keeps the warm-standby copy of management-platform on the VM
|
||||
# (178.18.243.51) in sync with the real source of truth on this
|
||||
# server (/root/CloudOps/platform), pushed directly over SSH.
|
||||
#
|
||||
# Replaces the old pull-platform-backup.sh + deploy-platform.sh
|
||||
# pair on the VM, which pulled/redeployed a platform-backup-*.tar.gz
|
||||
# tarball of /root/management-platform (the pre-migration Docker-era
|
||||
# deploy dir). That tarball generation was disabled on 2026-08-14
|
||||
# when the platform's source of truth moved to git-tracked
|
||||
# /root/CloudOps + Jenkins — nothing replaced it on the VM side, so
|
||||
# the standby silently kept re-deploying its last cached snapshot
|
||||
# (found stale at 2+ months old during PFE failover investigation).
|
||||
#
|
||||
# config.py is intentionally NOT sourced from /root/CloudOps/platform
|
||||
# (gitignored there, and can drift — see that file's own history).
|
||||
# The live /root/management-platform/config.py on THIS server is the
|
||||
# real one actually mounted into the running pod; it already contains
|
||||
# the RUNNING_ON_MAIN_SERVER auto-detect logic that makes the exact
|
||||
# same file behave correctly on both this server and the VM, so it's
|
||||
# copied byte-for-byte rather than templated.
|
||||
#
|
||||
# Run hourly via cron on this server (NOT on the VM — the VM has no
|
||||
# reason to pull, this server already pushes everywhere else it
|
||||
# manages, e.g. backup-k8s-apps.sh's VM tier).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
VM_HOST="178.18.243.51"
|
||||
VM_PORT="22"
|
||||
VM_USER="root"
|
||||
VM_KEY="/root/.ssh/id_rsa"
|
||||
VM_DEPLOY_DIR="/root/management-platform"
|
||||
|
||||
LOCAL_SRC="/root/CloudOps/platform/"
|
||||
LIVE_CONFIG="/root/management-platform/config.py"
|
||||
|
||||
# restore_start()/api_backup_run() in app.py reference this as a fixed
|
||||
# absolute path (/root/CloudOps/backup/restore-k8s-apps.sh) — it's a
|
||||
# sibling of platform/, never baked into the image, reached via the same
|
||||
# rw hostPath mount as everything else. Restoring FROM the standby (target
|
||||
# = remote, pointed at the main server over the tunnel) needs a local copy
|
||||
# here to scp across, so mirror it too, not just platform/.
|
||||
LOCAL_BACKUP_SRC="/root/CloudOps/backup/"
|
||||
VM_BACKUP_DIR="/root/CloudOps/backup"
|
||||
LOG="/root/CloudOps/scripts/sync-standby-platform.log"
|
||||
|
||||
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||
SCP_OPTS="-i $VM_KEY -P $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG"; }
|
||||
|
||||
log "🔄 Syncing platform code → $VM_USER@$VM_HOST:$VM_DEPLOY_DIR"
|
||||
|
||||
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "mkdir -p $VM_DEPLOY_DIR" || {
|
||||
log "❌ Cannot reach VM — aborting"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --itemize-changes so we can tell below whether anything actually
|
||||
# changed (drives the restart-only-if-needed decision).
|
||||
CHANGES=$(rsync -a --delete -e "ssh $SSH_OPTS" \
|
||||
--exclude venv --exclude __pycache__ --exclude '*.pyc' \
|
||||
--exclude config.py --exclude '*.log' \
|
||||
--itemize-changes \
|
||||
"$LOCAL_SRC" "$VM_USER@$VM_HOST:$VM_DEPLOY_DIR/" 2>>"$LOG")
|
||||
RSYNC_RC=$?
|
||||
|
||||
if [ $RSYNC_RC -ne 0 ]; then
|
||||
log "❌ rsync failed (rc=$RSYNC_RC)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "mkdir -p $VM_BACKUP_DIR"
|
||||
BACKUP_CHANGES=$(rsync -a --delete -e "ssh $SSH_OPTS" \
|
||||
--itemize-changes \
|
||||
"$LOCAL_BACKUP_SRC" "$VM_USER@$VM_HOST:$VM_BACKUP_DIR/" 2>>"$LOG")
|
||||
if [ $? -ne 0 ]; then
|
||||
log "❌ rsync of backup/ failed"
|
||||
exit 1
|
||||
fi
|
||||
CHANGES="${CHANGES}${BACKUP_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"
|
||||
exit 1
|
||||
fi
|
||||
if ! ssh $SSH_OPTS "$VM_USER@$VM_HOST" \
|
||||
"cmp -s $VM_DEPLOY_DIR/config.py $VM_DEPLOY_DIR/config.py.new 2>/dev/null"; then
|
||||
CONFIG_CHANGED="yes"
|
||||
fi
|
||||
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "mv $VM_DEPLOY_DIR/config.py.new $VM_DEPLOY_DIR/config.py"
|
||||
|
||||
if [ -z "$CHANGES" ] && [ -z "$CONFIG_CHANGED" ]; then
|
||||
log "✅ Already up to date — nothing changed, no restart needed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "📝 Changes detected:"
|
||||
echo "$CHANGES" | tee -a "$LOG"
|
||||
[ -n "$CONFIG_CHANGED" ] && log " (config.py changed too)"
|
||||
|
||||
log "📦 Syncing venv deps (requirements.txt) ..."
|
||||
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "
|
||||
cd $VM_DEPLOY_DIR
|
||||
chmod +x *.sh 2>/dev/null
|
||||
chmod +x *.py 2>/dev/null
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv venv
|
||||
venv/bin/python -m ensurepip --upgrade 2>/dev/null || true
|
||||
fi
|
||||
venv/bin/pip install --quiet -r requirements.txt
|
||||
" >>"$LOG" 2>&1
|
||||
|
||||
log "🔁 Restarting management-platform service on VM ..."
|
||||
ssh $SSH_OPTS "$VM_USER@$VM_HOST" "systemctl restart management-platform"
|
||||
sleep 3
|
||||
STATUS=$(ssh $SSH_OPTS "$VM_USER@$VM_HOST" "systemctl is-active management-platform 2>/dev/null")
|
||||
|
||||
if [ "$STATUS" = "active" ]; then
|
||||
log "✅ Standby platform restarted and running latest code"
|
||||
else
|
||||
log "⚠️ Service not active after restart (status: $STATUS) — check journalctl -u management-platform on the VM"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user