feat(16-01): add devices SNMP columns and snmp_metrics hypertable

- devices table: device_type (default 'routeros'), snmp_port (default 161),
  snmp_version, snmp_profile_id FK -> snmp_profiles, credential_profile_id
  FK -> credential_profiles, with lock_timeout = 3s for safe ALTER
- snmp_metrics: hypertable with 90-day retention, composite index on
  (device_id, metric_name, time DESC), RLS with tenant isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 18:22:27 -05:00
parent ad26335300
commit 37ed0242f4
2 changed files with 166 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
"""Add SNMP columns to devices table.
Revision ID: 039
Revises: 038
Create Date: 2026-03-21
Adds device_type (default 'routeros' for backward compatibility),
snmp_port, snmp_version, snmp_profile_id FK, and credential_profile_id FK.
Uses lock_timeout = 3s to fail fast rather than queue behind long-running
queries. Each ALTER TABLE ADD COLUMN with a non-volatile DEFAULT does NOT
rewrite the table in PostgreSQL 11+ -- the default is stored in pg_attribute
and applied on read, so this is a metadata-only change.
"""
import sqlalchemy as sa
from alembic import op
revision = "039"
down_revision = "038"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Fail fast if devices table is locked by another transaction
conn.execute(sa.text("SET lock_timeout = '3s'"))
conn.execute(
sa.text(
"ALTER TABLE devices"
" ADD COLUMN device_type TEXT NOT NULL DEFAULT 'routeros'"
)
)
conn.execute(
sa.text(
"ALTER TABLE devices"
" ADD COLUMN snmp_port INTEGER DEFAULT 161"
)
)
conn.execute(
sa.text(
"ALTER TABLE devices"
" ADD COLUMN snmp_version TEXT"
)
)
conn.execute(
sa.text(
"ALTER TABLE devices"
" ADD COLUMN snmp_profile_id UUID"
" REFERENCES snmp_profiles(id) ON DELETE SET NULL"
)
)
conn.execute(
sa.text(
"ALTER TABLE devices"
" ADD COLUMN credential_profile_id UUID"
" REFERENCES credential_profiles(id) ON DELETE SET NULL"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS credential_profile_id")
)
conn.execute(
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_profile_id")
)
conn.execute(
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_version")
)
conn.execute(
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_port")
)
conn.execute(
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS device_type")
)

View File

@@ -0,0 +1,80 @@
"""Create snmp_metrics hypertable for custom SNMP metric storage.
Revision ID: 040
Revises: 039
Create Date: 2026-03-21
Stores OID data that does not map to the standard interface_metrics or
health_metrics hypertables (e.g., UPS battery voltage, vendor-specific
counters, custom profile OIDs). Structured as a flexible key-value
time-series: metric_name + metric_group identify the series, value_numeric
and value_text hold typed values, index_value tracks SNMP table row index.
90-day retention matches existing hypertable policy. RLS enforces
tenant isolation.
"""
import sqlalchemy as sa
from alembic import op
revision = "040"
down_revision = "039"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("""
CREATE TABLE snmp_metrics (
time TIMESTAMPTZ NOT NULL,
device_id UUID NOT NULL,
tenant_id UUID NOT NULL,
metric_name TEXT NOT NULL,
metric_group TEXT NOT NULL,
value_numeric DOUBLE PRECISION,
value_text TEXT,
oid TEXT NOT NULL,
index_value TEXT
)
""")
)
conn.execute(sa.text("SELECT create_hypertable('snmp_metrics', 'time')"))
conn.execute(
sa.text(
"SELECT add_retention_policy('snmp_metrics', INTERVAL '90 days')"
)
)
conn.execute(
sa.text("""
CREATE INDEX idx_snmp_metrics_device_metric_time
ON snmp_metrics (device_id, metric_name, time DESC)
""")
)
conn.execute(
sa.text("ALTER TABLE snmp_metrics ENABLE ROW LEVEL SECURITY")
)
conn.execute(
sa.text("ALTER TABLE snmp_metrics FORCE ROW LEVEL SECURITY")
)
conn.execute(
sa.text("""
CREATE POLICY snmp_metrics_tenant_isolation
ON snmp_metrics
USING (
tenant_id::text = current_setting('app.current_tenant', true)
OR current_setting('app.current_tenant', true) = 'super_admin'
)
""")
)
def downgrade() -> None:
op.drop_table("snmp_metrics")