Compare commits

...

7 Commits

Author SHA1 Message Date
root
a307607a99 Migrate Grafana/Prometheus/SonarQube from docker-compose to k8s (step #10)
Real data migrated, not a fresh start: 1.2GB Prometheus TSDB, Grafana's
existing dashboards/DB (its admin password was already changed from
default — confirmed real, in-use data), SonarQube's data/logs/extensions
and its own Postgres DB (confirmed: the actual `management-platform`
SonarQube project survived, matching the Jenkinsfile's own
-Dsonar.projectKey). Old docker-compose services stopped, not removed —
left as a rollback path.

Real problems hit and fixed along the way (see k8s/monitoring/README.md
for the full detail, worth reading before touching any of this again):

- k3s's bundled Traefik defaults to a LoadBalancer Service, which
  immediately hijacked ports 80/443 via iptables DNAT (klipper-lb) out
  from under this VM's existing unrelated nginx-served app ("nqks") the
  moment k3s was installed. Patched Traefik to ClusterIP via a
  HelmChartConfig; nginx now reverse-proxies the 3 new domains to
  Traefik's ClusterIP instead of Traefik touching host ports at all.

- Caught my own mistake before it caused damage: "corrected" what I
  assumed was a typo (grafanna.nav.ovh -> grafana.nav.ovh) — the two-N
  version is what the user actually specified, and is what points at this
  VM; plain grafana.nav.ovh is a different, unrelated, pre-existing live
  server at 109.199.127.74. Renamed everything back before requesting any
  cert or touching that domain.

- Adding hostPort to the SonarQube pod (for Jenkins' hardcoded
  178.18.243.51:9000) broke pod-to-pod routing to that pod entirely —
  Traefik couldn't reach it (hung/504) while direct host-to-pod and
  host-to-ClusterIP both worked fine the whole time, which is what gave
  it away. Replaced with a host-level socat proxy (systemd unit) to the
  Service's ClusterIP instead, which touches nothing pod-level. Verified
  Jenkins' exact address still works AND Traefik routing to the same pod
  works simultaneously.

- SonarQube needed a DB migration after the version jump
  (DB_MIGRATION_NEEDED), then OOM'd during post-migration rule
  re-registration on the compose file's original 256MB web heap — bumped
  SONAR_WEB_JAVAADDITIONALOPTS.

TLS: real Let's Encrypt certs via certbot (HTTP-01, nginx already owns
port 80 so no DNS-01/OVH-webhook replication needed on this second
cluster), auto-renewing. Access: HTTP Basic Auth in front of all three
domains for browser access; SonarQube also stays reachable unauthenticated
at 178.18.243.51:9000 specifically for Jenkins (which authenticates via
its own token, not BasicAuth — that path would break the scanner),
restricted to the main server's IP via ufw, not open to the internet.

Verified end-to-end: all three https://*.nav.ovh domains return 401
without credentials and load correctly with them; Jenkins' exact
SonarQube address (no auth) still returns 200; real data confirmed
present in Grafana, Prometheus, and SonarQube post-migration.
2026-08-21 16:01:39 +02:00
root
3188c88737 Make SonarQube analysis non-blocking in the pipeline
Prompted by migrating SonarQube to k8s mid-session, which briefly broke
Jenkins' connectivity to it (server URL config points at a fixed
IP:port that had to move). That's exactly the kind of outage this stage
shouldn't be able to turn into a failed deploy: analysis quality isn't a
deploy gate the way the build/Sonar-unrelated stages are.

Wrapped in catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') —
if SonarQube is unreachable for any reason, the stage fails visibly (shows
red in the stage view) and the build is marked UNSTABLE (not hidden), but
the pipeline continues to Deploy instead of stopping there.
2026-08-21 15:40:05 +02:00
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
17 changed files with 984 additions and 37 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}"

72
k8s/monitoring/README.md Normal file
View File

@@ -0,0 +1,72 @@
# Monitoring stack on k8s (VM: 178.18.243.51)
Migrated from docker-compose (`/root/monitoring`, `/root/CI-CD/sonarqube` on
the VM — left `docker compose stop`'d, not removed, as a rollback path) into
the VM's own k3s cluster, with real data migrated (not a fresh start):
Prometheus TSDB, Grafana dashboards/DB, SonarQube data+DB (confirmed intact:
the real `management-platform` SonarQube project and Grafana's existing
admin password both survived).
## Apply order
```
kubectl apply -f monitoring-pvcs.yaml
# migrate data into the PVCs via a loader pod per volume (see chat history —
# not scripted, was a one-time manual migration)
kubectl apply -f monitoring-apps.yaml
kubectl apply -f monitoring-ingress.yaml
```
## Domains — READ THIS BEFORE TOUCHING DNS
- `grafanna.nav.ovh` (note: **two Ns** — `grafana.nav.ovh` without the typo
is a DIFFERENT, unrelated, pre-existing server at 109.199.127.74. Do not
point anything at plain `grafana.nav.ovh` or touch that record.)
- `sonar.nav.ovh`
- `prom.nav.ovh`
All three already existed as A records pointing at 178.18.243.51 before this
work; only `nginx-vhost.conf` + a Let's Encrypt cert (via certbot, HTTP-01,
auto-renews) make them actually resolve to something.
## Why nginx is in front of Traefik, not Traefik directly on 80/443
This VM already runs an unrelated app ("nqks") on nginx, owning ports 80/443.
k3s's bundled Traefik defaults to a LoadBalancer Service, which on a
single-node k3s claims host 80/443 via iptables DNAT (klipper-lb) —
this silently hijacked nqks's traffic the first time (found and fixed
during this session). Traefik's Service is patched to ClusterIP-only via
a HelmChartConfig (`kubectl get helmchartconfig -n kube-system traefik`);
nginx reverse-proxies the 3 domains to Traefik's ClusterIP instead, and
handles TLS + BasicAuth itself.
## Access control
- Browser access to all 3 domains: HTTP Basic Auth (`/etc/nginx/.htpasswd-monitoring`
on the VM, user `admin`) — ask whoever ran this migration for the password,
it's not in git.
- **SonarQube is also reachable directly at `178.18.243.51:9000`, no auth** —
this is deliberate, not an oversight. Jenkins' SonarQube server config
(main cluster, `hudson.plugins.sonar.SonarGlobalConfiguration.xml`) is
hardcoded to that exact address and authenticates via its own token, not
BasicAuth (which would break the scanner). `sonarqube-jenkins-proxy.service`
(systemd, socat) forwards host:9000 -> the sonarqube Service's ClusterIP;
ufw restricts port 9000 to the main server's IP only, not the open
internet. Do not add BasicAuth in front of this specific path.
- Fragility note: the socat unit hardcodes the Service's ClusterIP. If the
`sonarqube` Service in the `monitoring` namespace is ever deleted and
recreated (not just the pod — pod recreates keep the same ClusterIP),
update the `TCP:<ip>:9000` target in
`/etc/systemd/system/sonarqube-jenkins-proxy.service` on the VM to match.
## Gotchas hit building this (context for next time)
- Don't add a k8s Service of type LoadBalancer on this cluster without first
patching it to ClusterIP/setting a HelmChartConfig — same hijack risk as
Traefik's default.
- Don't add `hostPort` to a pod that also needs normal in-cluster (Service/
Ingress) traffic to reach it — it breaks pod-to-pod routing to that pod
(confirmed directly: Traefik -> sonarqube hung/504'd the whole time
hostPort was present, while direct host-to-pod and host-to-ClusterIP both
worked fine — the asymmetry was the tell). Use a host-level proxy (socat)
or a NodePort in the 30000-32767 range instead.
- SonarQube's DB was mid-upgrade (`DB_MIGRATION_NEEDED`) after the version
jump — needed one `POST /api/system/migrate_db` (admin/admin was already
changed on the real instance; used the actual admin session) — and the
post-migration rule re-registration OOM'd on the compose file's original
256MB web heap; bumped `SONAR_WEB_JAVAADDITIONALOPTS` to `-Xms512m -Xmx1536m`.

View File

@@ -0,0 +1,203 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: monitoring
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'server1-node'
static_configs:
- targets: ['173.249.20.244:9100']
labels:
server: 'server1'
- job_name: 'my-cadvisor'
static_configs:
- targets: ['173.249.20.244:8094']
labels:
server: 'server1'
owner: 'ameni'
- job_name: 'server1-blackbox'
static_configs:
- targets: ['173.249.20.244:9115']
labels:
server: 'server1'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: monitoring
spec:
replicas: 1
selector:
matchLabels: {app: prometheus}
strategy: {type: Recreate}
template:
metadata:
labels: {app: prometheus}
spec:
securityContext:
fsGroup: 65534
containers:
- name: prometheus
image: prom/prometheus:latest
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=15d
- --storage.tsdb.path=/prometheus
ports: [{containerPort: 9090}]
resources:
limits: {memory: 512Mi}
volumeMounts:
- {name: config, mountPath: /etc/prometheus/prometheus.yml, subPath: prometheus.yml}
- {name: data, mountPath: /prometheus}
volumes:
- name: config
configMap: {name: prometheus-config}
- name: data
persistentVolumeClaim: {claimName: prometheus-data}
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: monitoring
spec:
selector: {app: prometheus}
ports: [{port: 9090, targetPort: 9090}]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: monitoring
spec:
replicas: 1
selector:
matchLabels: {app: grafana}
strategy: {type: Recreate}
template:
metadata:
labels: {app: grafana}
spec:
securityContext:
fsGroup: 472
containers:
- name: grafana
image: grafana/grafana:latest
env:
- {name: GF_SECURITY_ADMIN_PASSWORD, value: admin}
ports: [{containerPort: 3000}]
resources:
limits: {memory: 256Mi}
volumeMounts:
- {name: data, mountPath: /var/lib/grafana}
volumes:
- name: data
persistentVolumeClaim: {claimName: grafana-data}
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: monitoring
spec:
selector: {app: grafana}
ports: [{port: 3000, targetPort: 3000}]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarqube-db
namespace: monitoring
spec:
replicas: 1
selector:
matchLabels: {app: sonarqube-db}
strategy: {type: Recreate}
template:
metadata:
labels: {app: sonarqube-db}
spec:
securityContext:
fsGroup: 999
containers:
- name: sonarqube-db
image: postgres:15
env:
- {name: POSTGRES_USER, value: sonar}
- {name: POSTGRES_PASSWORD, value: sonar}
- {name: POSTGRES_DB, value: sonar}
ports: [{containerPort: 5432}]
volumeMounts:
- {name: data, mountPath: /var/lib/postgresql/data}
volumes:
- name: data
persistentVolumeClaim: {claimName: sonarqube-db-data}
---
apiVersion: v1
kind: Service
metadata:
name: sonarqube-db
namespace: monitoring
spec:
selector: {app: sonarqube-db}
ports: [{port: 5432, targetPort: 5432}]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarqube
namespace: monitoring
spec:
replicas: 1
selector:
matchLabels: {app: sonarqube}
strategy: {type: Recreate}
template:
metadata:
labels: {app: sonarqube}
spec:
securityContext:
fsGroup: 1000
containers:
- name: sonarqube
image: sonarqube:community
env:
- {name: SONAR_ES_BOOTSTRAP_CHECKS_DISABLE, value: "true"}
- {name: SONAR_JDBC_URL, value: "jdbc:postgresql://sonarqube-db:5432/sonar"}
- {name: SONAR_JDBC_USERNAME, value: sonar}
- {name: SONAR_JDBC_PASSWORD, value: sonar}
- {name: SONAR_SEARCH_JAVAADDITIONALOPTS, value: "-Xms512m -Xmx512m"}
- {name: SONAR_WEB_JAVAADDITIONALOPTS, value: "-Xms512m -Xmx1536m"}
ports: [{containerPort: 9000}]
resources:
limits: {memory: 3Gi}
volumeMounts:
- {name: data, mountPath: /opt/sonarqube/data}
- {name: logs, mountPath: /opt/sonarqube/logs}
- {name: extensions, mountPath: /opt/sonarqube/extensions}
volumes:
- name: data
persistentVolumeClaim: {claimName: sonarqube-data}
- name: logs
persistentVolumeClaim: {claimName: sonarqube-logs}
- name: extensions
persistentVolumeClaim: {claimName: sonarqube-extensions}
---
apiVersion: v1
kind: Service
metadata:
name: sonarqube
namespace: monitoring
spec:
selector: {app: sonarqube}
ports: [{port: 9000, targetPort: 9000}]

View File

@@ -0,0 +1,53 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: monitoring
spec:
ingressClassName: traefik
rules:
- host: grafana.nav.ovh
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port: {number: 3000}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sonarqube
namespace: monitoring
spec:
ingressClassName: traefik
rules:
- host: sonar.nav.ovh
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sonarqube
port: {number: 9000}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prometheus
namespace: monitoring
spec:
ingressClassName: traefik
rules:
- host: prom.nav.ovh
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus
port: {number: 9090}

View File

@@ -0,0 +1,76 @@
apiVersion: v1
kind: Namespace
metadata:
name: monitoring
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prometheus-data
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 3Gi
storageClassName: local-path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: grafana-data
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
storageClassName: local-path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarqube-data
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi
storageClassName: local-path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarqube-logs
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
storageClassName: local-path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarqube-extensions
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
storageClassName: local-path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sonarqube-db-data
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
storageClassName: local-path

View File

@@ -0,0 +1,102 @@
server {
server_name grafanna.nav.ovh;
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd-monitoring;
proxy_pass http://10.43.200.43;
proxy_set_header Host grafanna.nav.ovh;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/grafanna.nav.ovh/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/grafanna.nav.ovh/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
server_name sonar.nav.ovh;
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd-monitoring;
proxy_pass http://10.43.200.43;
proxy_set_header Host sonar.nav.ovh;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/grafanna.nav.ovh/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/grafanna.nav.ovh/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
server_name prom.nav.ovh;
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd-monitoring;
proxy_pass http://10.43.200.43;
proxy_set_header Host prom.nav.ovh;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/grafanna.nav.ovh/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/grafanna.nav.ovh/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = grafanna.nav.ovh) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name grafanna.nav.ovh;
return 404; # managed by Certbot
}
server {
if ($host = sonar.nav.ovh) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name sonar.nav.ovh;
return 404; # managed by Certbot
}
server {
if ($host = prom.nav.ovh) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name prom.nav.ovh;
return 404; # managed by Certbot
}

View File

@@ -0,0 +1,13 @@
[Unit]
Description=TCP proxy: host:9000 -> k8s sonarqube Service ClusterIP:9000 (for Jenkins, which expects this exact address)
After=k3s.service
Wants=k3s.service
[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:9000,fork,reuseaddr TCP:10.43.4.19:9000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

27
platform/Jenkinsfile vendored
View File

@@ -35,15 +35,24 @@ pipeline {
stage('SonarQube Analysis') {
steps {
container('sonar-scanner') {
withSonarQubeEnv('sonarqube') {
script {
def scannerHome = tool 'sonar-scanner'
sh """
${scannerHome}/bin/sonar-scanner \
-Dsonar.projectKey=management-platform \
-Dsonar.sources=platform \
-Dsonar.python.version=3
"""
// Analysis quality, not a deploy gate: SonarQube being
// unreachable (moved server, migration in progress,
// temporary outage) shouldn't block shipping an
// otherwise-good build. catchError marks the build
// UNSTABLE (visible in the UI, doesn't need separate
// watching) and lets the pipeline continue to Deploy
// instead of failing the whole run.
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
withSonarQubeEnv('sonarqube') {
script {
def scannerHome = tool 'sonar-scanner'
sh """
${scannerHome}/bin/sonar-scanner \
-Dsonar.projectKey=management-platform \
-Dsonar.sources=platform \
-Dsonar.python.version=3
"""
}
}
}
}

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

@@ -1,4 +1,5 @@
boto3==1.43.5
flask
psutil
kubernetes
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"