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 "✅"; rm -f "$APP_DIR/manifests.err"; } \
|
||||||
|| { echo "⚠️ FAILED (see manifests.err)"; ERRORS+=("$app:manifests:$(tr '\n' ' ' < "$APP_DIR/manifests.err" | cut -c1-200)"); }
|
|| { 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) ----
|
# ---- 2. Secret (plaintext, encryption-at-rest deferred) ----
|
||||||
echo -n " 🔑 Secret ($secret_name) ... "
|
echo -n " 🔑 Secret ($secret_name) ... "
|
||||||
kubectl get secret "$secret_name" -n "$ns" -o yaml \
|
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" \
|
"${BACKUP_NAME}.tar.gz.sha256" \
|
||||||
"${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
"${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true
|
||||||
TIER2_STATUS="ok"
|
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
|
else
|
||||||
echo " ⚠️ VM transfer failed (rc=$VM_SCP_RC) — local backup is safe, continuing..."
|
echo " ⚠️ VM transfer failed (rc=$VM_SCP_RC) — local backup is safe, continuing..."
|
||||||
TIER2_STATUS="failed:rc=${VM_SCP_RC}"
|
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') {
|
stage('SonarQube Analysis') {
|
||||||
steps {
|
steps {
|
||||||
container('sonar-scanner') {
|
container('sonar-scanner') {
|
||||||
withSonarQubeEnv('sonarqube') {
|
// Analysis quality, not a deploy gate: SonarQube being
|
||||||
script {
|
// unreachable (moved server, migration in progress,
|
||||||
def scannerHome = tool 'sonar-scanner'
|
// temporary outage) shouldn't block shipping an
|
||||||
sh """
|
// otherwise-good build. catchError marks the build
|
||||||
${scannerHome}/bin/sonar-scanner \
|
// UNSTABLE (visible in the UI, doesn't need separate
|
||||||
-Dsonar.projectKey=management-platform \
|
// watching) and lets the pipeline continue to Deploy
|
||||||
-Dsonar.sources=platform \
|
// instead of failing the whole run.
|
||||||
-Dsonar.python.version=3
|
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 os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
@@ -69,11 +70,11 @@ def _stream_restore(job_id, cmd):
|
|||||||
restore_jobs[job_id]['status'] = 'error'
|
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()}
|
backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()}
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
['bash', script_path],
|
['bash', script_path] + (extra_args or []),
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
text=True, bufsize=1
|
text=True, bufsize=1
|
||||||
)
|
)
|
||||||
@@ -441,8 +442,21 @@ def api_backup_run():
|
|||||||
'message': f'Backup script not found at {script}'
|
'message': f'Backup script not found at {script}'
|
||||||
}), 500
|
}), 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())
|
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()
|
t.start()
|
||||||
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
|
return jsonify({'success': True, 'job_id': job_id, 'status': 'started'})
|
||||||
|
|
||||||
@@ -869,36 +883,74 @@ def restore_start():
|
|||||||
if not remote_ip:
|
if not remote_ip:
|
||||||
return jsonify({'error': 'remote_ip required'}), 400
|
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 auth_method == 'key':
|
||||||
if not ssh_key_path:
|
if not ssh_key_path:
|
||||||
return jsonify({'error': 'ssh_key_path required'}), 400
|
return jsonify({'error': 'ssh_key_path required'}), 400
|
||||||
ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_opts}"
|
common_evars.append(f"ansible_ssh_private_key_file={ssh_key_path}")
|
||||||
scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_opts}"
|
|
||||||
else:
|
else:
|
||||||
if not ssh_password:
|
if not ssh_password:
|
||||||
return jsonify({'error': 'ssh_password required'}), 400
|
return jsonify({'error': 'ssh_password required'}), 400
|
||||||
ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_opts}"
|
common_evars.append(f"ansible_password='{ssh_password}'")
|
||||||
scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_opts}"
|
|
||||||
|
|
||||||
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 = (
|
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"echo 'Apps: {apps_label}' && "
|
||||||
f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && "
|
+ " && ".join(per_app_cmds)
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -241,8 +241,26 @@ def _with_status(names, sidecars):
|
|||||||
return result
|
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():
|
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():
|
def get_vm_backups_with_status():
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ boto3==1.43.5
|
|||||||
flask
|
flask
|
||||||
psutil
|
psutil
|
||||||
kubernetes
|
kubernetes
|
||||||
|
ansible-core
|
||||||
@@ -410,11 +410,15 @@ function renderBackupList(items, id, source) {
|
|||||||
const detailsBtn = typeof showBackupDetails === 'function'
|
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>`
|
? `<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 `
|
return `
|
||||||
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
<div class="backup-item" id="bk-item-${source}-${name.replace(/[^a-z0-9]/gi, '_')}">
|
||||||
<div class="backup-item-main">
|
<div class="backup-item-main">
|
||||||
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
<span class="status-dot status-dot-${status}" title="${title}"></span>
|
||||||
<span class="backup-name">${escapeHtml(name)}</span>
|
<span class="backup-name">${escapeHtml(name)}</span>
|
||||||
|
${appsLabel}
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-actions">
|
<div class="backup-actions">
|
||||||
${detailsBtn}
|
${detailsBtn}
|
||||||
@@ -489,7 +493,14 @@ async function runManualBackup() {
|
|||||||
if (wrapper) wrapper.style.display = '';
|
if (wrapper) wrapper.style.display = '';
|
||||||
if (logEl) logEl.innerHTML = '';
|
if (logEl) logEl.innerHTML = '';
|
||||||
try {
|
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();
|
const d = await r.json();
|
||||||
if (!d.success) throw new Error(d.message || 'Failed');
|
if (!d.success) throw new Error(d.message || 'Failed');
|
||||||
manualBackupJobId = d.job_id;
|
manualBackupJobId = d.job_id;
|
||||||
|
|||||||
@@ -252,7 +252,52 @@
|
|||||||
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
<div class="card-title"><i class="fas fa-shield-halved"></i> Manual Backup</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:4px 0 12px;">
|
<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">
|
<button class="btn btn-primary" onclick="runManualBackup()" id="manual-backup-btn">
|
||||||
<i class="fas fa-play"></i> Run Backup Now
|
<i class="fas fa-play"></i> Run Backup Now
|
||||||
</button>
|
</button>
|
||||||
@@ -288,6 +333,11 @@
|
|||||||
<div class="backup-item-main">
|
<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="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>
|
<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>
|
||||||
<div class="backup-actions">
|
<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>
|
<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">
|
<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="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>
|
<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>
|
||||||
<div class="backup-actions">
|
<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>
|
<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/.
|
# here to scp across, so mirror it too, not just platform/.
|
||||||
LOCAL_BACKUP_SRC="/root/CloudOps/backup/"
|
LOCAL_BACKUP_SRC="/root/CloudOps/backup/"
|
||||||
VM_BACKUP_DIR="/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"
|
LOG="/root/CloudOps/scripts/sync-standby-platform.log"
|
||||||
|
|
||||||
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
SSH_OPTS="-i $VM_KEY -p $VM_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=15"
|
||||||
@@ -82,6 +89,17 @@ if [ $? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
CHANGES="${CHANGES}${BACKUP_CHANGES}"
|
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=""
|
CONFIG_CHANGED=""
|
||||||
if ! scp $SCP_OPTS "$LIVE_CONFIG" "$VM_USER@$VM_HOST:$VM_DEPLOY_DIR/config.py.new" &>>"$LOG"; then
|
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"
|
log "❌ Could not copy live config.py to VM"
|
||||||
|
|||||||
Reference in New Issue
Block a user