"""Add endpointlocks (admin-only endpoint quarantine). One table behind the endpoint lock: an admin-set refusal of operator commands on a single endpoint, independent of policy and permission. Deliberately separate from the computers plugin's policyactionholds. That table suppresses ONE desired-state action on the policy-resolution axis and expires on a timer; this one refuses NON-ADMIN CALLERS on the whole endpoint and does not expire at all. Two concepts, two tables, two enforcement points - collapsing them would recreate the axis-mixing the surrounding refactor exists to undo. No expiresat column, and that is the design rather than an omission. A lock stands for a physical-world condition (machine mid-repair, validated part running, box that must not reboot during an audit) and those do not end on a schedule. A TTL would silently unlock a machine someone still has their hands inside. Ships EMPTY. No endpoint has ever been locked before this migration, so there is nothing to backfill and every endpoint keeps behaving exactly as it did. Idempotent: the create checks the live schema first, so a re-run and a db.create_all() site both stay clean on strict MySQL 8. """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'endpoints0018endpointlocks' down_revision = 'endpoints0017healthrollup' branch_labels = None depends_on = None LOCKS = 'endpointlocks' def _has_table(table_name): from sqlalchemy import inspect return inspect(op.get_bind()).has_table(table_name) def upgrade(): if not _has_table(LOCKS): op.create_table( LOCKS, sa.Column('endpointlockid', sa.Integer(), nullable=False), sa.Column('assetid', sa.Integer(), nullable=False), sa.Column('reason', sa.Text(), nullable=False), sa.Column('lockedbyuserid', sa.Integer(), nullable=True), sa.Column('lockedbyusername', sa.String(length=100), nullable=True), sa.Column('lockedat', sa.DateTime(), nullable=False), # Null while the lock stands. Setting it is what releases the lock, # so there is no separate active flag able to drift out of step. sa.Column('releasedat', sa.DateTime(), nullable=True), sa.Column('releasedbyuserid', sa.Integer(), nullable=True), sa.Column('releasedbyusername', sa.String(length=100), nullable=True), sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), # SET NULL, not CASCADE: deleting the admin who locked a machine # must not delete the record that it was locked. The username # snapshot columns survive to say who it was. sa.ForeignKeyConstraint(['lockedbyuserid'], ['users.userid'], ondelete='SET NULL'), sa.ForeignKeyConstraint(['releasedbyuserid'], ['users.userid'], ondelete='SET NULL'), sa.PrimaryKeyConstraint('endpointlockid'), ) # Serves the hot path: "is there an active lock on this asset", asked on # every enqueue and every endpoint detail load. NOT unique on assetid - # released rows accumulate per endpoint as history, and a unique # constraint would make the second lock in a machine's life fail. The # one-active-lock invariant is enforced on write instead. op.create_index('idx_endpointlocks_assetactive', LOCKS, ['assetid', 'releasedat']) def downgrade(): # Safe to drop: the table is a system of record for quarantine state, but a # downgrade past this revision removes the enforcement that reads it, so # leaving orphaned rows behind would be worse - they would silently reapply # if the revision were ever re-applied. if _has_table(LOCKS): op.drop_index('idx_endpointlocks_assetactive', table_name=LOCKS) op.drop_table(LOCKS)