feat(01-01): add RouterConfigSnapshot/Diff/Change ORM models and tests
- Add RouterConfigSnapshot model with Transit ciphertext config_text and SHA-256 plaintext hash for deduplication - Add RouterConfigDiff model for unified diffs between snapshots - Add RouterConfigChange model for parsed semantic changes - Export all three from app.models barrel file - Add unit tests for importability, table names, columns, and types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from app.models.config_template import ConfigTemplate, ConfigTemplateTag, Templa
|
|||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.maintenance_window import MaintenanceWindow
|
from app.models.maintenance_window import MaintenanceWindow
|
||||||
from app.models.api_key import ApiKey
|
from app.models.api_key import ApiKey
|
||||||
|
from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Tenant",
|
"Tenant",
|
||||||
@@ -32,4 +33,7 @@ __all__ = [
|
|||||||
"AuditLog",
|
"AuditLog",
|
||||||
"MaintenanceWindow",
|
"MaintenanceWindow",
|
||||||
"ApiKey",
|
"ApiKey",
|
||||||
|
"RouterConfigSnapshot",
|
||||||
|
"RouterConfigDiff",
|
||||||
|
"RouterConfigChange",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -176,3 +176,164 @@ class ConfigPushOperation(Base):
|
|||||||
f"<ConfigPushOperation id={self.id} device_id={self.device_id} "
|
f"<ConfigPushOperation id={self.id} device_id={self.device_id} "
|
||||||
f"status={self.status!r}>"
|
f"status={self.status!r}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RouterConfigSnapshot(Base):
|
||||||
|
"""A point-in-time router configuration snapshot.
|
||||||
|
|
||||||
|
The config_text column stores OpenBao Transit ciphertext (vault:v1:...).
|
||||||
|
Plaintext router config is NEVER stored in PostgreSQL -- it is encrypted
|
||||||
|
via Transit before insertion and decrypted on read.
|
||||||
|
|
||||||
|
The sha256_hash column stores the SHA-256 hex digest of the PLAINTEXT
|
||||||
|
config (computed before encryption). This enables deduplication: if
|
||||||
|
the hash matches the latest snapshot for a device, no new row is created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "router_config_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
server_default=func.gen_random_uuid(),
|
||||||
|
)
|
||||||
|
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# OpenBao Transit ciphertext (vault:v1:...). Plaintext NEVER stored.
|
||||||
|
config_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# SHA-256 hex digest of the PLAINTEXT config, for deduplication.
|
||||||
|
sha256_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
collected_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<RouterConfigSnapshot id={self.id} device_id={self.device_id} "
|
||||||
|
f"hash={self.sha256_hash[:8]!r}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RouterConfigDiff(Base):
|
||||||
|
"""Unified diff between two consecutive router config snapshots.
|
||||||
|
|
||||||
|
Stores the diff_text (unified diff output) along with line counts
|
||||||
|
for quick display without re-parsing. References both the old and
|
||||||
|
new snapshot by foreign key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "router_config_diffs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
server_default=func.gen_random_uuid(),
|
||||||
|
)
|
||||||
|
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
old_snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("router_config_snapshots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
new_snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("router_config_snapshots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
diff_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
lines_added: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
lines_removed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<RouterConfigDiff id={self.id} device_id={self.device_id} "
|
||||||
|
f"+{self.lines_added}/-{self.lines_removed}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RouterConfigChange(Base):
|
||||||
|
"""A parsed change extracted from a router config diff.
|
||||||
|
|
||||||
|
Each change represents a semantic modification (e.g., firewall rule
|
||||||
|
added, IP address changed) parsed from the unified diff. The component
|
||||||
|
field identifies the RouterOS section (e.g., 'ip/firewall/filter').
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "router_config_changes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
server_default=func.gen_random_uuid(),
|
||||||
|
)
|
||||||
|
diff_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("router_config_diffs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# RouterOS config section path (e.g., 'ip/firewall/filter')
|
||||||
|
component: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Human-readable description of the change
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Raw diff line(s), nullable for synthesized changes
|
||||||
|
raw_line: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<RouterConfigChange id={self.id} diff_id={self.diff_id} "
|
||||||
|
f"component={self.component!r}>"
|
||||||
|
)
|
||||||
|
|||||||
93
backend/tests/unit/test_config_snapshot_models.py
Normal file
93
backend/tests/unit/test_config_snapshot_models.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Unit tests for RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange models.
|
||||||
|
|
||||||
|
Verifies STOR-01 (table/column structure) and STOR-05 (config_text stores ciphertext).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_config_snapshot_importable():
|
||||||
|
"""RouterConfigSnapshot can be imported from app.models."""
|
||||||
|
from app.models import RouterConfigSnapshot
|
||||||
|
assert RouterConfigSnapshot is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_config_diff_importable():
|
||||||
|
"""RouterConfigDiff can be imported from app.models."""
|
||||||
|
from app.models import RouterConfigDiff
|
||||||
|
assert RouterConfigDiff is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_config_change_importable():
|
||||||
|
"""RouterConfigChange can be imported from app.models."""
|
||||||
|
from app.models import RouterConfigChange
|
||||||
|
assert RouterConfigChange is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_tablename():
|
||||||
|
"""RouterConfigSnapshot.__tablename__ is correct."""
|
||||||
|
from app.models import RouterConfigSnapshot
|
||||||
|
assert RouterConfigSnapshot.__tablename__ == "router_config_snapshots"
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_tablename():
|
||||||
|
"""RouterConfigDiff.__tablename__ is correct."""
|
||||||
|
from app.models import RouterConfigDiff
|
||||||
|
assert RouterConfigDiff.__tablename__ == "router_config_diffs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_tablename():
|
||||||
|
"""RouterConfigChange.__tablename__ is correct."""
|
||||||
|
from app.models import RouterConfigChange
|
||||||
|
assert RouterConfigChange.__tablename__ == "router_config_changes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_columns():
|
||||||
|
"""RouterConfigSnapshot has all required columns."""
|
||||||
|
from app.models import RouterConfigSnapshot
|
||||||
|
table = RouterConfigSnapshot.__table__
|
||||||
|
expected = {"id", "device_id", "tenant_id", "config_text", "sha256_hash", "collected_at", "created_at"}
|
||||||
|
actual = {c.name for c in table.columns}
|
||||||
|
assert expected.issubset(actual), f"Missing columns: {expected - actual}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_columns():
|
||||||
|
"""RouterConfigDiff has all required columns."""
|
||||||
|
from app.models import RouterConfigDiff
|
||||||
|
table = RouterConfigDiff.__table__
|
||||||
|
expected = {
|
||||||
|
"id", "device_id", "tenant_id", "old_snapshot_id", "new_snapshot_id",
|
||||||
|
"diff_text", "lines_added", "lines_removed", "created_at",
|
||||||
|
}
|
||||||
|
actual = {c.name for c in table.columns}
|
||||||
|
assert expected.issubset(actual), f"Missing columns: {expected - actual}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_columns():
|
||||||
|
"""RouterConfigChange has all required columns."""
|
||||||
|
from app.models import RouterConfigChange
|
||||||
|
table = RouterConfigChange.__table__
|
||||||
|
expected = {
|
||||||
|
"id", "diff_id", "device_id", "tenant_id",
|
||||||
|
"component", "summary", "raw_line", "created_at",
|
||||||
|
}
|
||||||
|
actual = {c.name for c in table.columns}
|
||||||
|
assert expected.issubset(actual), f"Missing columns: {expected - actual}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_config_text_is_text_type():
|
||||||
|
"""config_text column type is Text (documents Transit ciphertext contract)."""
|
||||||
|
from app.models import RouterConfigSnapshot
|
||||||
|
col = RouterConfigSnapshot.__table__.c.config_text
|
||||||
|
assert isinstance(col.type, Text), f"Expected Text, got {type(col.type)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_sha256_hash_is_string_64():
|
||||||
|
"""sha256_hash column type is String(64) for plaintext hash deduplication."""
|
||||||
|
from app.models import RouterConfigSnapshot
|
||||||
|
col = RouterConfigSnapshot.__table__.c.sha256_hash
|
||||||
|
assert isinstance(col.type, String), f"Expected String, got {type(col.type)}"
|
||||||
|
assert col.type.length == 64, f"Expected length 64, got {col.type.length}"
|
||||||
Reference in New Issue
Block a user