"""SQLAlchemy models for config backup tables.""" import uuid from datetime import datetime from sqlalchemy import ( Boolean, DateTime, ForeignKey, Integer, LargeBinary, SmallInteger, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.database import Base class ConfigBackupRun(Base): """Metadata for a single config backup run. The actual config content (export.rsc and backup.bin) lives in the tenant's bare git repository at GIT_STORE_PATH/{tenant_id}.git. This table provides the timeline view and per-run metadata without duplicating file content in PostgreSQL. """ __tablename__ = "config_backup_runs" 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, ) # Git commit hash in the tenant's bare repo where this backup is stored. commit_sha: Mapped[str] = mapped_column(Text, nullable=False) # Trigger type: 'scheduled' | 'manual' | 'pre-restore' | 'checkpoint' | 'config-change' trigger_type: Mapped[str] = mapped_column(String(20), nullable=False) # Lines added/removed vs the prior export.rsc for this device. # NULL for the first backup (no prior version to diff against). lines_added: Mapped[int | None] = mapped_column(Integer, nullable=True) lines_removed: Mapped[int | None] = mapped_column(Integer, nullable=True) # Encryption metadata: NULL=plaintext, 1=client-side AES-GCM, 2=OpenBao Transit encryption_tier: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) # 12-byte AES-GCM nonce for Tier 1 (client-side) backups; NULL for plaintext/Transit encryption_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) def __repr__(self) -> str: return ( f"" ) class ConfigBackupSchedule(Base): """Per-tenant default and per-device override backup schedule config. A row with device_id=NULL is the tenant-level default (daily at 2am). A row with a specific device_id overrides the tenant default for that device. """ __tablename__ = "config_backup_schedules" __table_args__ = ( UniqueConstraint("tenant_id", "device_id", name="uq_backup_schedule_tenant_device"), ) id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, server_default=func.gen_random_uuid(), ) tenant_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, ) # NULL = tenant-level default schedule; non-NULL = device-specific override. device_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("devices.id", ondelete="CASCADE"), nullable=True, ) # Standard cron expression (5 fields). Default: daily at 2am UTC. cron_expression: Mapped[str] = mapped_column( String(100), nullable=False, default="0 2 * * *", server_default="0 2 * * *", ) enabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="TRUE", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) def __repr__(self) -> str: scope = f"device={self.device_id}" if self.device_id else f"tenant={self.tenant_id}" return ( f"" ) class ConfigPushOperation(Base): """Tracks pending two-phase config push operations for panic-revert recovery. Before pushing a config, a row is inserted with status='pending_verification'. If the API pod restarts during the 60-second verification window, the startup handler checks this table and either commits (deletes the RouterOS scheduler job) or marks the operation as 'failed'. This prevents the panic-revert scheduler from firing and reverting a successful push after an API restart. See Pitfall 6 in 04-RESEARCH.md for the full failure scenario. """ __tablename__ = "config_push_operations" 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, ) # Git commit SHA we'd revert to if the push fails. pre_push_commit_sha: Mapped[str] = mapped_column(Text, nullable=False) # RouterOS scheduler job name created on the device for panic-revert. scheduler_name: Mapped[str] = mapped_column(String(255), nullable=False) # 'pending_verification' | 'committed' | 'reverted' | 'failed' status: Mapped[str] = mapped_column( String(30), nullable=False, default="pending_verification", server_default="pending_verification", ) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) completed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) def __repr__(self) -> str: return ( f"" ) 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"" ) 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"" ) 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"" )