mirror of
https://github.com/PegaProx/project-pegaprox.git
synced 2026-08-12 15:27:47 +08:00
Final batch of the CodeAnt re-scan (all adversarially verified): - app.py CSRF: the check ran only for JSON/form bodies (if sensitive), so a cross-site enctype=text/plain form POST (a browser 'simple request') skipped it — now enforced for every state-changing non-exempt /api/*. - app.py http-response-splitting (x2 redirect handlers): the untrusted request Host was reflected into the Location header; now stripped/charset-rejected before use (a configured domain always wins). - app.py CSP: dropped 'unsafe-eval' from script-src (Babel is pre-compiled, never runs in-browser). - SSRF: plugins/notifications _send_apprise (prefix blocklist missed decimal/IPv6/metadata) and nodes._safe_repo_url (root-run bash curl) now go through the url_security guard. - siem._row_to_target masks secret settings keys (token/password/api_key/secret/authorization) so a siem.view holder can't read the raw credential back out. - IDOR: power rate routes (get/upsert/delete, __default__ skipped), drift.acknowledge_event (gate on the event's cluster), schedules.get_schedules (was fail-open on empty clusters field -> now get_user_clusters). 277 passing. Residual (LOW, follow-up): vmware/xhm migration-list per-task cluster filter.
550 lines
23 KiB
Python
550 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Config Drift Detection — NS May 2026.
|
|
|
|
Snapshots core cluster configuration on a schedule, compares against an
|
|
admin-set baseline, emits drift events when something changed.
|
|
|
|
Categories tracked:
|
|
- vm_config per-VM `qm config` (qemu) / `pct config` (lxc)
|
|
- storage /etc/pve/storage.cfg (via /api2/json/storage)
|
|
- network per-node /etc/network/interfaces (via /api2/json/nodes/<n>/network)
|
|
- cluster_options /etc/pve/datacenter.cfg (via /api2/json/cluster/options)
|
|
|
|
Workflow:
|
|
1. Admin clicks "Set baseline" — current state stored in drift_baselines.
|
|
2. Background scanner (every 6h) and manual trigger compute current state,
|
|
diff against baseline, write drift_events.
|
|
3. UI shows open events with diff. Admin "Acknowledge" or "Promote diff
|
|
to new baseline" (latter rebases so the change is no longer flagged).
|
|
|
|
Diff is a list of {path, op, before, after} entries. Keeps it explainable.
|
|
|
|
Drift events also fan out via the alerts notification handlers, so the same
|
|
Slack/Discord/ntfy/web-push channels users have configured for alerts also
|
|
get drift notifications without extra plumbing.
|
|
"""
|
|
import json
|
|
import time
|
|
import uuid
|
|
import logging
|
|
import threading
|
|
from datetime import datetime
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from pegaprox.globals import cluster_managers
|
|
from pegaprox.utils.auth import require_auth
|
|
from pegaprox.api.helpers import check_cluster_access
|
|
from pegaprox.core.db import get_db
|
|
from pegaprox.models.permissions import ROLE_ADMIN
|
|
|
|
bp = Blueprint('drift', __name__)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Snapshot fetchers — return (kind, scope, dict) tuples
|
|
# scope is a sub-identifier (e.g. vmid for vm_config, node name for network)
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
# Keys whose change should NOT count as drift (volatile bookkeeping)
|
|
_VM_VOLATILE_KEYS = {'meta', 'lock', 'parent', 'snapshot', 'snapstate',
|
|
'lastsnapshot', 'pending', 'running-machine', 'running-qemu',
|
|
'digest'}
|
|
_NETWORK_VOLATILE = {'active'}
|
|
|
|
# NS: PVE returns these comma-separated lists in non-deterministic order (e.g.
|
|
# `content` on storages, `tags` on VMs). Without sorting them we'd flood the
|
|
# admin with bogus drift events. Sort once at fetch time, the diff becomes
|
|
# stable.
|
|
_CSV_NORMALIZE_KEYS = {'content', 'tags', 'nodes'}
|
|
|
|
|
|
def _strip_volatile(d, volatile_keys):
|
|
if not isinstance(d, dict): return d
|
|
out = {}
|
|
for k, v in d.items():
|
|
if k in volatile_keys: continue
|
|
if k in _CSV_NORMALIZE_KEYS and isinstance(v, str) and ',' in v:
|
|
parts = [p.strip() for p in v.split(',') if p.strip()]
|
|
v = ','.join(sorted(parts))
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def _fetch_state(mgr, cluster_id):
|
|
"""Return list of (kind, scope, snapshot_dict). Robust to per-call fails;
|
|
a single dead node shouldn't poison the whole snapshot."""
|
|
out = []
|
|
host, port = mgr.host, mgr.api_port
|
|
|
|
# cluster options
|
|
try:
|
|
r = mgr._api_get(f"https://{host}:{port}/api2/json/cluster/options")
|
|
if r is not None and getattr(r, 'status_code', 0) == 200:
|
|
data = r.json().get('data') or {}
|
|
out.append(('cluster_options', 'global', data))
|
|
except Exception as e:
|
|
logging.debug(f"[drift] cluster/options fetch failed: {e}")
|
|
|
|
# storage configs (cluster-wide)
|
|
try:
|
|
r = mgr._api_get(f"https://{host}:{port}/api2/json/storage")
|
|
if r is not None and getattr(r, 'status_code', 0) == 200:
|
|
for s in (r.json().get('data') or []):
|
|
sid = s.get('storage')
|
|
if not sid: continue
|
|
# _strip_volatile sorts comma-list fields like `content`, `nodes`
|
|
# which Proxmox returns in non-deterministic order
|
|
out.append(('storage', sid, _strip_volatile(s, set())))
|
|
except Exception as e:
|
|
logging.debug(f"[drift] storage fetch failed: {e}")
|
|
|
|
# per-node network state
|
|
try:
|
|
nodes = (mgr.nodes or {}).keys()
|
|
except Exception:
|
|
nodes = []
|
|
for node in nodes:
|
|
try:
|
|
r = mgr._api_get(f"https://{host}:{port}/api2/json/nodes/{node}/network")
|
|
if r is None or getattr(r, 'status_code', 0) != 200:
|
|
continue
|
|
for nic in (r.json().get('data') or []):
|
|
iface = nic.get('iface')
|
|
if not iface: continue
|
|
clean = _strip_volatile(nic, _NETWORK_VOLATILE)
|
|
out.append(('network', f"{node}/{iface}", clean))
|
|
except Exception as e:
|
|
logging.debug(f"[drift] network/{node} fetch failed: {e}")
|
|
|
|
# per-VM/CT configs
|
|
try:
|
|
for r in (mgr.get_vm_resources() or []):
|
|
t = r.get('type')
|
|
vmid = r.get('vmid')
|
|
node = r.get('node')
|
|
if t not in ('qemu', 'lxc') or not vmid or not node:
|
|
continue
|
|
url = f"https://{host}:{port}/api2/json/nodes/{node}/{t}/{vmid}/config"
|
|
try:
|
|
resp = mgr._api_get(url)
|
|
if resp is None or getattr(resp, 'status_code', 0) != 200:
|
|
continue
|
|
cfg = resp.json().get('data') or {}
|
|
clean = _strip_volatile(cfg, _VM_VOLATILE_KEYS)
|
|
out.append(('vm_config', f"{t}/{vmid}", clean))
|
|
except Exception:
|
|
continue
|
|
except Exception as e:
|
|
logging.debug(f"[drift] vm enumeration failed: {e}")
|
|
|
|
return out
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Diff
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _flat_diff(baseline, current):
|
|
"""Compare two dicts, return list of {path, op, before, after}.
|
|
Top-level keys only — Proxmox configs are flat enough that this is fine
|
|
and produces much more readable output than recursive deep-diffs."""
|
|
if not isinstance(baseline, dict): baseline = {}
|
|
if not isinstance(current, dict): current = {}
|
|
keys = set(baseline.keys()) | set(current.keys())
|
|
diffs = []
|
|
for k in sorted(keys):
|
|
b, c = baseline.get(k), current.get(k)
|
|
if b == c: continue
|
|
if k in baseline and k not in current:
|
|
diffs.append({'path': k, 'op': 'removed', 'before': b, 'after': None})
|
|
elif k not in baseline and k in current:
|
|
diffs.append({'path': k, 'op': 'added', 'before': None, 'after': c})
|
|
else:
|
|
diffs.append({'path': k, 'op': 'changed', 'before': b, 'after': c})
|
|
return diffs
|
|
|
|
|
|
def _severity_for(kind, diffs):
|
|
"""Heuristic — VM disks/network changes are louder than e.g. description edits."""
|
|
paths = {d['path'] for d in diffs}
|
|
sensitive_vm = {'scsi0', 'sata0', 'virtio0', 'ide0', 'net0', 'net1', 'net2',
|
|
'memory', 'cores', 'cpu', 'ostype', 'boot', 'bootdisk',
|
|
'cipassword', 'sshkeys'}
|
|
sensitive_storage = {'shared', 'export', 'server', 'username', 'content', 'pool'}
|
|
sensitive_network = {'address', 'netmask', 'gateway', 'bridge_ports', 'type',
|
|
'bond_slaves', 'vlan-raw-device'}
|
|
if kind == 'cluster_options':
|
|
return 'warning'
|
|
if kind == 'vm_config' and (paths & sensitive_vm):
|
|
return 'warning'
|
|
if kind == 'storage' and (paths & sensitive_storage):
|
|
return 'warning'
|
|
if kind == 'network' and (paths & sensitive_network):
|
|
return 'critical'
|
|
return 'info'
|
|
|
|
|
|
def _short_summary(kind, scope, diffs):
|
|
keys = ', '.join(d['path'] for d in diffs[:4])
|
|
if len(diffs) > 4:
|
|
keys += f", +{len(diffs) - 4} more"
|
|
return f"{kind} {scope}: {keys}"
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Persistence helpers
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _baseline_key(cluster_id, kind, scope):
|
|
return f"{cluster_id}:{kind}:{scope}"
|
|
|
|
|
|
def _load_baselines(cluster_id):
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute(
|
|
"SELECT id, kind, scope, snapshot, created_at, created_by "
|
|
"FROM drift_baselines WHERE cluster_id = ?",
|
|
(cluster_id,)
|
|
)
|
|
out = {}
|
|
for r in c.fetchall():
|
|
try:
|
|
snap = json.loads(r['snapshot'])
|
|
except Exception:
|
|
snap = {}
|
|
out[(r['kind'], r['scope'])] = {
|
|
'id': r['id'],
|
|
'snapshot': snap,
|
|
'created_at': r['created_at'],
|
|
'created_by': r['created_by'] or '',
|
|
}
|
|
return out
|
|
except Exception as e:
|
|
logging.warning(f"[drift] baseline load failed: {e}")
|
|
return {}
|
|
|
|
|
|
def _set_baseline(cluster_id, kind, scope, snapshot, user):
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
# one baseline per (cluster,kind,scope) — replace
|
|
c.execute(
|
|
"DELETE FROM drift_baselines WHERE cluster_id=? AND kind=? AND scope=?",
|
|
(cluster_id, kind, scope)
|
|
)
|
|
c.execute('''
|
|
INSERT INTO drift_baselines (id, cluster_id, kind, scope, snapshot, created_at, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
''', (uuid.uuid4().hex[:12], cluster_id, kind, scope,
|
|
json.dumps(snapshot), datetime.now().isoformat(), user))
|
|
get_db().conn.commit()
|
|
except Exception as e:
|
|
logging.warning(f"[drift] set_baseline failed: {e}")
|
|
|
|
|
|
def _record_event(cluster_id, kind, scope, diffs, severity, summary):
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO drift_events (cluster_id, kind, scope, severity,
|
|
summary, diff, detected_at, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'open')
|
|
''', (cluster_id, kind, scope, severity, summary,
|
|
json.dumps(diffs), datetime.now().isoformat()))
|
|
eid = c.lastrowid
|
|
get_db().conn.commit()
|
|
return eid
|
|
except Exception as e:
|
|
logging.warning(f"[drift] record_event failed: {e}")
|
|
return None
|
|
|
|
|
|
def _current_user():
|
|
try:
|
|
u = request.session.get('user') if hasattr(request, 'session') else ''
|
|
if isinstance(u, dict):
|
|
return u.get('username', '') or ''
|
|
return u or ''
|
|
except Exception:
|
|
return ''
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Scan
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
def _scan_cluster(cluster_id, autobaseline=False):
|
|
"""Snapshot + diff for one cluster. Returns dict with counts/events.
|
|
If autobaseline=True and no baseline exists yet, set one without firing
|
|
drift events (initial seeding behavior)."""
|
|
if cluster_id not in cluster_managers:
|
|
return {'error': 'cluster not found'}
|
|
mgr = cluster_managers[cluster_id]
|
|
if not getattr(mgr, 'is_connected', False):
|
|
return {'error': 'cluster offline', 'status': 'skipped'}
|
|
|
|
state = _fetch_state(mgr, cluster_id)
|
|
baselines = _load_baselines(cluster_id)
|
|
|
|
new_events = []
|
|
seeded = 0
|
|
seen_keys = set()
|
|
for kind, scope, snap in state:
|
|
seen_keys.add((kind, scope))
|
|
bk = baselines.get((kind, scope))
|
|
if not bk:
|
|
if autobaseline:
|
|
_set_baseline(cluster_id, kind, scope, snap, 'system')
|
|
seeded += 1
|
|
continue
|
|
diffs = _flat_diff(bk['snapshot'], snap)
|
|
if not diffs: continue
|
|
sev = _severity_for(kind, diffs)
|
|
summary = _short_summary(kind, scope, diffs)
|
|
eid = _record_event(cluster_id, kind, scope, diffs, sev, summary)
|
|
new_events.append({'id': eid, 'kind': kind, 'scope': scope,
|
|
'severity': sev, 'summary': summary})
|
|
|
|
# detect deletions: baseline keys that are no longer in current state
|
|
removed = []
|
|
for (kind, scope), bk in baselines.items():
|
|
if (kind, scope) in seen_keys:
|
|
continue
|
|
diffs = [{'path': '*', 'op': 'removed', 'before': bk['snapshot'], 'after': None}]
|
|
summary = f"{kind} {scope}: object removed"
|
|
sev = 'warning' if kind in ('vm_config', 'storage') else 'info'
|
|
eid = _record_event(cluster_id, kind, scope, diffs, sev, summary)
|
|
new_events.append({'id': eid, 'kind': kind, 'scope': scope,
|
|
'severity': sev, 'summary': summary})
|
|
removed.append(scope)
|
|
|
|
# fire alert handler so configured Slack/Discord/etc. + push pick it up
|
|
if new_events:
|
|
try:
|
|
from pegaprox.background import alerts as alerts_mod
|
|
count = len(new_events)
|
|
top_sev = 'critical' if any(e['severity'] == 'critical' for e in new_events) \
|
|
else 'warning' if any(e['severity'] == 'warning' for e in new_events) \
|
|
else 'info'
|
|
payload = {
|
|
'alert_name': 'Config Drift',
|
|
'severity': top_sev,
|
|
'cluster_id': cluster_id,
|
|
'message': f"{count} config drift event(s) detected",
|
|
'metric': 'drift',
|
|
'target_type': 'cluster',
|
|
'target_name': cluster_id,
|
|
'timestamp': datetime.now().isoformat(),
|
|
}
|
|
for h in alerts_mod._notification_handlers:
|
|
try: h(payload)
|
|
except Exception: pass
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
'ok': True,
|
|
'cluster_id': cluster_id,
|
|
'events_count': len(new_events),
|
|
'seeded_baselines': seeded,
|
|
'removed': removed,
|
|
'events': new_events,
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Background scanner — every 6h. Also runs on first call after import.
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
_scanner_running = False
|
|
_scanner_lock = threading.Lock()
|
|
SCAN_INTERVAL = 6 * 3600
|
|
|
|
|
|
def _scanner_loop():
|
|
while _scanner_running:
|
|
try:
|
|
for cid in list(cluster_managers.keys()):
|
|
try:
|
|
_scan_cluster(cid, autobaseline=True)
|
|
except Exception as e:
|
|
logging.debug(f"[drift] scan {cid} failed: {e}")
|
|
except Exception as e:
|
|
logging.warning(f"[drift] scanner iteration failed: {e}")
|
|
# break sleep into 1-sec chunks so shutdown is responsive
|
|
for _ in range(SCAN_INTERVAL):
|
|
if not _scanner_running:
|
|
return
|
|
time.sleep(1)
|
|
|
|
|
|
def start_scanner():
|
|
global _scanner_running
|
|
with _scanner_lock:
|
|
if _scanner_running:
|
|
return
|
|
_scanner_running = True
|
|
t = threading.Thread(target=_scanner_loop, daemon=True, name='drift-scanner')
|
|
t.start()
|
|
logging.info("[drift] scanner thread started (6h cadence)")
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Endpoints
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/drift/status', methods=['GET'])
|
|
@require_auth(perms=['cluster.view'])
|
|
def drift_status(cluster_id):
|
|
"""Counts of open events grouped by kind + severity."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('''
|
|
SELECT kind, severity, COUNT(*) AS n
|
|
FROM drift_events
|
|
WHERE cluster_id = ? AND status = 'open'
|
|
GROUP BY kind, severity
|
|
''', (cluster_id,))
|
|
by_kind = {}
|
|
total = 0
|
|
sev_total = {'critical': 0, 'warning': 0, 'info': 0}
|
|
for r in c.fetchall():
|
|
d = by_kind.setdefault(r['kind'], {'critical': 0, 'warning': 0, 'info': 0})
|
|
d[r['severity']] = r['n']
|
|
sev_total[r['severity']] = sev_total.get(r['severity'], 0) + r['n']
|
|
total += r['n']
|
|
|
|
# baseline count
|
|
c.execute("SELECT COUNT(*) AS n FROM drift_baselines WHERE cluster_id=?", (cluster_id,))
|
|
baseline_count = c.fetchone()['n']
|
|
|
|
# last scan: pick max(detected_at) from events, fallback to baseline created_at
|
|
c.execute("SELECT MAX(detected_at) AS t FROM drift_events WHERE cluster_id=?", (cluster_id,))
|
|
last_event_t = c.fetchone()['t']
|
|
|
|
return jsonify({
|
|
'cluster_id': cluster_id,
|
|
'open_total': total,
|
|
'by_kind': by_kind,
|
|
'by_severity': sev_total,
|
|
'baselines': baseline_count,
|
|
'last_event_at': last_event_t,
|
|
})
|
|
except Exception as e:
|
|
logging.exception('handler error in drift.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/drift/events', methods=['GET'])
|
|
@require_auth(perms=['cluster.view'])
|
|
def list_events(cluster_id):
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
status = request.args.get('status', 'open')
|
|
limit = max(1, min(int(request.args.get('limit', '100')), 500))
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
if status == 'all':
|
|
c.execute('''SELECT * FROM drift_events WHERE cluster_id=?
|
|
ORDER BY detected_at DESC LIMIT ?''',
|
|
(cluster_id, limit))
|
|
else:
|
|
c.execute('''SELECT * FROM drift_events WHERE cluster_id=? AND status=?
|
|
ORDER BY detected_at DESC LIMIT ?''',
|
|
(cluster_id, status, limit))
|
|
out = []
|
|
for r in c.fetchall():
|
|
d = dict(r)
|
|
try:
|
|
d['diff'] = json.loads(d['diff'])
|
|
except Exception:
|
|
pass
|
|
out.append(d)
|
|
return jsonify({'events': out})
|
|
except Exception as e:
|
|
logging.exception('handler error in drift.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/drift/events/<int:eid>/acknowledge', methods=['POST'])
|
|
@require_auth(perms=['admin.audit'])
|
|
def acknowledge_event(eid):
|
|
"""Mark event acknowledged. If body has promote=true, also rebase the
|
|
baseline so the same change won't trigger again next scan."""
|
|
body = request.get_json(silent=True) or {}
|
|
promote = bool(body.get('promote', False))
|
|
user = _current_user()
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('SELECT * FROM drift_events WHERE id=?', (eid,))
|
|
ev = c.fetchone()
|
|
if not ev:
|
|
return jsonify({'error': 'not found'}), 404
|
|
# NS Jul 2026 (CodeAnt IDOR) — tenant gate on the event's cluster before ack/promote
|
|
# (was ack-ing / rebaselining another tenant's drift events).
|
|
from pegaprox.api.helpers import check_cluster_access
|
|
ok, err = check_cluster_access(ev['cluster_id'])
|
|
if not ok:
|
|
return err
|
|
c.execute('''UPDATE drift_events SET status='acknowledged',
|
|
acknowledged_at=?, acknowledged_by=? WHERE id=?''',
|
|
(datetime.now().isoformat(), user, eid))
|
|
|
|
if promote:
|
|
# re-fetch the live state for that scope and store as new baseline
|
|
cid = ev['cluster_id']
|
|
mgr = cluster_managers.get(cid)
|
|
if mgr:
|
|
state = _fetch_state(mgr, cid)
|
|
for kind, scope, snap in state:
|
|
if kind == ev['kind'] and scope == ev['scope']:
|
|
_set_baseline(cid, kind, scope, snap, user)
|
|
break
|
|
get_db().conn.commit()
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
logging.exception('handler error in drift.py'); return jsonify({'error': 'internal error'}), 500
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/drift/scan', methods=['POST'])
|
|
@require_auth(perms=['admin.audit'])
|
|
def manual_scan(cluster_id):
|
|
"""Trigger immediate scan. Body: {seed: bool} — if true and no baseline
|
|
exists, write current state as baseline silently (initial setup)."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
body = request.get_json(silent=True) or {}
|
|
seed = bool(body.get('seed', False))
|
|
return jsonify(_scan_cluster(cluster_id, autobaseline=seed))
|
|
|
|
|
|
@bp.route('/api/clusters/<cluster_id>/drift/baseline', methods=['POST'])
|
|
@require_auth(perms=['admin.audit'])
|
|
def reset_baseline(cluster_id):
|
|
"""Reset all baselines for the cluster from current state. Drops outstanding
|
|
open events implicitly (they reference old baselines)."""
|
|
ok, err = check_cluster_access(cluster_id)
|
|
if not ok: return err
|
|
if cluster_id not in cluster_managers:
|
|
return jsonify({'error': 'cluster not found'}), 404
|
|
mgr = cluster_managers[cluster_id]
|
|
user = _current_user()
|
|
state = _fetch_state(mgr, cluster_id)
|
|
try:
|
|
c = get_db().conn.cursor()
|
|
c.execute('DELETE FROM drift_baselines WHERE cluster_id=?', (cluster_id,))
|
|
c.execute("UPDATE drift_events SET status='superseded' WHERE cluster_id=? AND status='open'",
|
|
(cluster_id,))
|
|
for kind, scope, snap in state:
|
|
c.execute('''
|
|
INSERT INTO drift_baselines (id, cluster_id, kind, scope, snapshot, created_at, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
''', (uuid.uuid4().hex[:12], cluster_id, kind, scope,
|
|
json.dumps(snap), datetime.now().isoformat(), user))
|
|
get_db().conn.commit()
|
|
return jsonify({'ok': True, 'baselines': len(state)})
|
|
except Exception as e:
|
|
logging.exception('handler error in drift.py'); return jsonify({'error': 'internal error'}), 500
|