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:
Jason Staack
2026-03-12 22:31:28 -05:00
parent 79453fa115
commit 72d0ae2856

View 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()