diff --git a/backend/app/services/config_diff_service.py b/backend/app/services/config_diff_service.py new file mode 100644 index 0000000..6d3160e --- /dev/null +++ b/backend/app/services/config_diff_service.py @@ -0,0 +1,168 @@ +"""Config diff generation service. + +Generates unified diffs between consecutive router config snapshots. +Called after a new non-duplicate snapshot is stored. Best-effort: +errors are logged and counted via Prometheus, never raised. + +Plaintext config is decrypted via OpenBao Transit, diffed in-memory, +and the diff text is stored in router_config_diffs. Plaintext is +never persisted or logged. +""" + +import difflib +import logging +import time + +from prometheus_client import Counter, Histogram +from sqlalchemy import text + +from app.services.openbao_service import OpenBaoTransitService + +logger = logging.getLogger(__name__) + +# --- Prometheus metrics --- + +config_diff_generated_total = Counter( + "config_diff_generated_total", + "Total successful diff generations", +) +config_diff_errors_total = Counter( + "config_diff_errors_total", + "Total diff generation errors", + ["error_type"], +) +config_diff_generation_duration_seconds = Histogram( + "config_diff_generation_duration_seconds", + "Time to generate a config diff", +) + + +async def generate_and_store_diff( + device_id: str, + tenant_id: str, + new_snapshot_id: str, + session, +) -> None: + """Generate unified diff between new snapshot and previous, store in router_config_diffs. + + Best-effort: errors are logged and counted, never raised. + Called from handle_config_snapshot after successful INSERT. + """ + start_time = time.monotonic() + try: + # 1. Query previous snapshot + result = await session.execute( + text( + "SELECT id, config_text FROM router_config_snapshots " + "WHERE device_id = CAST(:device_id AS uuid) " + "AND id != CAST(:new_snapshot_id AS uuid) " + "ORDER BY collected_at DESC LIMIT 1" + ), + {"device_id": device_id, "new_snapshot_id": new_snapshot_id}, + ) + prev_row = result.fetchone() + + # 2. No previous snapshot = first snapshot for device + if prev_row is None: + logger.debug( + "First snapshot for device %s, no diff to generate", device_id + ) + return + + old_snapshot_id = prev_row._mapping["id"] + old_ciphertext = prev_row._mapping["config_text"] + + # 3. Query new snapshot config_text + new_result = await session.execute( + text( + "SELECT config_text FROM router_config_snapshots " + "WHERE id = CAST(:new_snapshot_id AS uuid)" + ), + {"new_snapshot_id": new_snapshot_id}, + ) + new_ciphertext = new_result.scalar_one() + + # 4. Decrypt both via OpenBao Transit + openbao = OpenBaoTransitService() + try: + old_plaintext = await openbao.decrypt(tenant_id, old_ciphertext) + new_plaintext = await openbao.decrypt(tenant_id, new_ciphertext) + except Exception as exc: + # 5. Decrypt failure: log warning, increment counter, return + logger.warning( + "Transit decrypt failed for diff (device %s): %s", + device_id, + exc, + ) + config_diff_errors_total.labels(error_type="decrypt_failed").inc() + return + finally: + await openbao.close() + + # 6. Generate unified diff + old_lines = old_plaintext.decode("utf-8").splitlines() + new_lines = new_plaintext.decode("utf-8").splitlines() + diff_lines = list( + difflib.unified_diff(old_lines, new_lines, lineterm="", n=3) + ) + + # 7. If empty diff, skip INSERT + diff_text = "\n".join(diff_lines) + if not diff_text: + logger.debug( + "Empty diff for device %s (identical content), skipping", + device_id, + ) + return + + # 8. Count lines added/removed (exclude +++ and --- headers) + lines_added = sum( + 1 for line in diff_lines + if line.startswith("+") and not line.startswith("++") + ) + lines_removed = sum( + 1 for line in diff_lines + if line.startswith("-") and not line.startswith("--") + ) + + # 9. INSERT into router_config_diffs + await session.execute( + text( + "INSERT INTO router_config_diffs " + "(device_id, tenant_id, old_snapshot_id, new_snapshot_id, " + "diff_text, lines_added, lines_removed) " + "VALUES (CAST(:device_id AS uuid), CAST(:tenant_id AS uuid), " + "CAST(:old_snapshot_id AS uuid), CAST(:new_snapshot_id AS uuid), " + ":diff_text, :lines_added, :lines_removed)" + ), + { + "device_id": device_id, + "tenant_id": tenant_id, + "old_snapshot_id": str(old_snapshot_id), + "new_snapshot_id": new_snapshot_id, + "diff_text": diff_text, + "lines_added": lines_added, + "lines_removed": lines_removed, + }, + ) + + # 10. Commit + await session.commit() + + config_diff_generated_total.inc() + duration = time.monotonic() - start_time + config_diff_generation_duration_seconds.observe(duration) + logger.info( + "Config diff generated for device %s: +%d/-%d lines", + device_id, + lines_added, + lines_removed, + ) + + except Exception as exc: + logger.warning( + "Diff generation error for device %s (non-fatal): %s", + device_id, + exc, + ) + config_diff_errors_total.labels(error_type="db_error").inc()