Compare commits
7 Commits
9361b9c4c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a307607a99 | ||
|
|
3188c88737 | ||
|
|
9b631f527c | ||
|
|
5802b200a7 | ||
|
|
7ddce24e62 | ||
|
|
054a9e1fcb | ||
|
|
459c2974f0 |
179
ansible/dr-bootstrap.yml
Normal file
179
ansible/dr-bootstrap.yml
Normal 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
2
ansible/inventory.ini
Normal 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'
|
||||
60
ansible/sanitize_k8s_manifest.py
Executable file
60
ansible/sanitize_k8s_manifest.py
Executable 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()
|
||||
@@ -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
72
k8s/monitoring/README.md
Normal 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`.
|
||||
203
k8s/monitoring/monitoring-apps.yaml
Normal file
203
k8s/monitoring/monitoring-apps.yaml
Normal 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}]
|
||||
53
k8s/monitoring/monitoring-ingress.yaml
Normal file
53
k8s/monitoring/monitoring-ingress.yaml
Normal 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}
|
||||
76
k8s/monitoring/monitoring-pvcs.yaml
Normal file
76
k8s/monitoring/monitoring-pvcs.yaml
Normal 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
|
||||
102
k8s/monitoring/nginx-vhost.conf
Normal file
102
k8s/monitoring/nginx-vhost.conf
Normal 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
|
||||
|
||||
|
||||
}
|
||||
13
k8s/monitoring/sonarqube-jenkins-proxy.service
Normal file
13
k8s/monitoring/sonarqube-jenkins-proxy.service
Normal 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
27
platform/Jenkinsfile
vendored
@@ -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
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
platform/app.py
100
platform/app.py
@@ -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())
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
boto3==1.43.5
|
||||
flask
|
||||
psutil
|
||||
kubernetes
|
||||
kubernetes
|
||||
ansible-core
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user