A fresh `docker compose up` never came up. Migration 028 imported the
live ORM (`from app.models.asset import Asset`) and ran `db.query(Asset)`
against a database that is only at revision 028, so the SELECT asked for
`assets.intune_device_id` — a column revision 033 adds. Alembic aborted
with UndefinedColumn, the whole migration chain rolled back (one
transaction for all revisions), and the backend crash-looped against an
empty database.
028 is now raw SQL: one CTE that flips the legacy Nessus rows and writes
the audit entries, comparing `source::text` / `status::text` so it no
longer depends on today's enum labels either.
env.py switches to transaction_per_migration. 027 adds the
ASSET_DEACTIVATED enum label that 028 inserts, and PostgreSQL refuses to
use a new enum value in the transaction that added it ("unsafe use of new
value"). Per-migration transactions also make a failed upgrade resumable
instead of all-or-nothing.
Verified end to end from a clean `git archive` checkout: migrations run
027 -> 046, default admin is created, backend reports healthy, login
through the frontend proxy returns a session cookie.
tests/test_migrations_no_orm_imports.py locks the bug class down: no
migration may import app code, because the ORM mapping is always newer
than the schema of the revision it runs at.
93 lines
3.4 KiB
Python
93 lines
3.4 KiB
Python
"""Reconcile legacy Nessus-sourced assets without a pinned nessus_host_uuid
|
|
|
|
Revision ID: 028
|
|
Revises: 027
|
|
Create Date: 2026-06-02 14:00:00.000000
|
|
|
|
Field feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
|
|
Nessus scan): the event-driven reconciliation in
|
|
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
|
|
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
|
|
created by older Nessus syncs that matched by IP or hostname (before the
|
|
UUID-backfill path was added) have `nessus_host_uuid IS NULL` and are
|
|
silently skipped. After a reduced scan they stay ACTIVE forever, which
|
|
contradicts the "sync-driven INACTIVE" promise.
|
|
|
|
This migration is the one-shot cleanup for the existing backlog (33 rows
|
|
in the test instance). New rows created after the 0006 commit (which
|
|
adds the diagnostic log + the `reconcile_legacy_nessus_assets` runtime
|
|
helper) are handled in code.
|
|
|
|
Idempotent: a row already INACTIVE matches the filter only when the
|
|
status check is omitted, so the body re-checks status before flipping.
|
|
Audit-logged via the same `_audit_asset_status` helper as the runtime
|
|
path so the audit trail is consistent.
|
|
|
|
Downgrade is a no-op — restoring a row to ACTIVE would require operator
|
|
intent, not a migration reversal.
|
|
"""
|
|
from alembic import op
|
|
|
|
|
|
revision = "028"
|
|
down_revision = "027"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# Raw SQL on purpose: importing app.models here binds the *current*
|
|
# ORM mapping to a database that is only at revision 028, so a fresh
|
|
# install crashes on columns added by later revisions (033 added
|
|
# assets.intune_device_id). A migration may only touch the schema as
|
|
# it exists at its own revision.
|
|
#
|
|
# `source::text` / `status::text` instead of enum literals keeps this
|
|
# independent of whichever labels the enum types carry at this point.
|
|
op.execute(
|
|
"""
|
|
WITH flipped AS (
|
|
UPDATE assets
|
|
SET status = 'INACTIVE'
|
|
WHERE source::text = 'NESSUS'
|
|
AND status::text = 'ACTIVE'
|
|
AND nessus_host_uuid IS NULL
|
|
RETURNING id, hostname
|
|
)
|
|
INSERT INTO audit_logs (
|
|
user_id, event_type, event_description,
|
|
resource_type, resource_id, old_value, new_value, timestamp
|
|
)
|
|
SELECT
|
|
NULL,
|
|
'ASSET_DEACTIVATED',
|
|
left(
|
|
'Asset ' || hostname || ': status active \u2192 inactive '
|
|
|| '(legacy Nessus-sourced asset without pinned '
|
|
|| 'nessus_host_uuid \u2014 flipped by alembic migration 028 '
|
|
|| '(reconcile_legacy_nessus_assets))',
|
|
500
|
|
),
|
|
'asset',
|
|
id::text,
|
|
'active',
|
|
json_build_object(
|
|
'status', 'inactive',
|
|
'hostname', hostname,
|
|
'reason', 'legacy Nessus-sourced asset without pinned '
|
|
|| 'nessus_host_uuid \u2014 flipped by alembic '
|
|
|| 'migration 028 (reconcile_legacy_nessus_assets)'
|
|
)::text,
|
|
now()
|
|
FROM flipped;
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# No-op. Restoring INACTIVE -> ACTIVE is an operator decision, not a
|
|
# migration reversal. The legacy rows can be re-activated by a fresh
|
|
# Nessus sync that reports them (event-driven revive in
|
|
# reconcile_missing_from_sync).
|
|
pass
|