feat(05-01): implement config diff service with Transit decrypt and difflib
- generate_and_store_diff decrypts old+new snapshots, produces unified diff - Stores diff in router_config_diffs with line counts - Best-effort: decrypt/DB errors logged, never raised - Prometheus metrics: generated_total, errors_total, duration_seconds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
168
backend/app/services/config_diff_service.py
Normal file
168
backend/app/services/config_diff_service.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user