"""route pre-Zone Agent installations without rewriting ownership history Revision ID: 0018_legacy_agent_routing Revises: 0017_agent_enrollment Create Date: 2026-08-28 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0018_legacy_agent_routing" down_revision: Union[str, None] = "0017_agent_enrollment" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None DEFAULT_ZONE_NAME = "Default Zone" # Every preflight and the eventual Site UPDATE use this exact resolver. An # exact trim/case-folded name match wins. The sole-Site fallback applies only # when there are zero exact matches and exactly one durable Site in the whole # database. Ambiguous exact matches and unresolved multi-Site installations # deliberately produce NULL resolved_site_id and fail before any UPDATE. RESOLVED_AGENT_SITES_CTE = """ site_catalog AS ( SELECT count(*) AS site_count, (array_agg(id ORDER BY id))[1] AS sole_site_id FROM sites ), unassigned_exact_matches AS ( SELECT agent.agent_id, agent.site_name, count(site.id) AS exact_match_count, ( array_agg(site.id ORDER BY site.id) FILTER (WHERE site.id IS NOT NULL) )[1] AS exact_site_id FROM minerops_site_agents AS agent LEFT JOIN sites AS site ON lower(btrim(site.name)) = lower(btrim(agent.site_name)) WHERE agent.site_id IS NULL GROUP BY agent.agent_id, agent.site_name ), resolved_unassigned_agents AS ( SELECT match.agent_id, match.site_name, match.exact_match_count, catalog.site_count, CASE WHEN match.exact_match_count = 1 THEN match.exact_site_id WHEN match.exact_match_count = 0 AND catalog.site_count = 1 THEN catalog.sole_site_id ELSE NULL END AS resolved_site_id FROM unassigned_exact_matches AS match CROSS JOIN site_catalog AS catalog ) """ def _format_rows(rows: list[tuple]) -> str: return ", ".join("/".join(repr(str(value)) for value in row) for row in rows) def upgrade() -> None: connection = op.get_bind() # Keep every preflight result stable until the corresponding routing and # history updates commit. Production must still take the Hub and all queue # producers offline before migration; these locks are the database-level # backstop against an accidental concurrent Site, Zone, Agent, snapshot, or # discovery write changing an already-checked assumption. connection.execute( sa.text( """ LOCK TABLE agent_zones, minerops_agent_discoveries, minerops_agent_snapshots, minerops_site_agents, sites IN SHARE ROW EXCLUSIVE MODE """ ) ) # Run every fail-closed preflight before the first UPDATE. Site resolution # is intentionally limited to legacy rows without an explicit Site # assignment; explicit site_id values are never candidates for replacement. site_match_failures = connection.execute( sa.text( f""" WITH {RESOLVED_AGENT_SITES_CTE} SELECT agent_id, site_name, exact_match_count, site_count FROM resolved_unassigned_agents WHERE resolved_site_id IS NULL ORDER BY agent_id """ ) ).all() if site_match_failures: raise RuntimeError( "Cannot route legacy Agent installations: each unassigned Agent " "must have exactly one Site name match after trim/case folding, " "or zero exact matches while the database contains exactly one " "Site. Ambiguous exact matches and unmatched multi-Site " "installations fail closed. Failing " "agent/site/exact-match-count/site-count rows: " f"{_format_rows(list(site_match_failures))}. Assign site_id " "explicitly, make Site names unambiguous, or identify the correct " "Site before retrying; no " "Agent, miner, MAC, queue, snapshot, or discovery data was changed." ) default_zone_failures = connection.execute( sa.text( f""" WITH {RESOLVED_AGENT_SITES_CTE}, effective_agents AS ( SELECT agent.agent_id, coalesce( agent.site_id, resolved.resolved_site_id ) AS site_id FROM minerops_site_agents AS agent LEFT JOIN resolved_unassigned_agents AS resolved ON resolved.agent_id = agent.agent_id WHERE agent.agent_zone_id IS NULL ) SELECT agent.agent_id, agent.site_id::text, count(zone.id) FROM effective_agents AS agent LEFT JOIN agent_zones AS zone ON zone.site_id = agent.site_id AND zone.is_enabled AND lower(btrim(zone.name)) = lower(:default_name) GROUP BY agent.agent_id, agent.site_id HAVING count(zone.id) <> 1 ORDER BY agent.agent_id """ ), {"default_name": DEFAULT_ZONE_NAME}, ).all() if default_zone_failures: raise RuntimeError( "Cannot route legacy Agent installations: each Agent without an " "explicit Zone must have exactly one enabled Default Zone in its " "effective Site. Failing agent/site/count rows: " f"{_format_rows(list(default_zone_failures))}. Repair the Site's " "Default Zone or assign agent_zone_id explicitly before retrying; " "no data was changed." ) zone_conflicts = connection.execute( sa.text( f""" WITH {RESOLVED_AGENT_SITES_CTE}, targets AS ( SELECT agent.agent_id, zone.id AS agent_zone_id FROM minerops_site_agents AS agent LEFT JOIN resolved_unassigned_agents AS resolved ON resolved.agent_id = agent.agent_id JOIN agent_zones AS zone ON zone.site_id = coalesce( agent.site_id, resolved.resolved_site_id ) AND zone.is_enabled AND lower(btrim(zone.name)) = lower(:default_name) WHERE agent.agent_zone_id IS NULL ) SELECT target.agent_zone_id::text, string_agg(target.agent_id, ',' ORDER BY target.agent_id), coalesce(string_agg(occupant.agent_id, ',' ORDER BY occupant.agent_id), '') FROM targets AS target LEFT JOIN minerops_site_agents AS occupant ON occupant.agent_zone_id = target.agent_zone_id GROUP BY target.agent_zone_id HAVING count(DISTINCT target.agent_id) > 1 OR count(occupant.agent_id) > 0 ORDER BY target.agent_zone_id """ ), {"default_name": DEFAULT_ZONE_NAME}, ).all() if zone_conflicts: raise RuntimeError( "Cannot route legacy Agent installations: one-Agent-per-Zone would " "be violated. Failing zone/target Agents/current Agent rows: " f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled " "Zones explicitly before retrying; no data was changed." ) connection.execute( sa.text( f""" WITH {RESOLVED_AGENT_SITES_CTE} UPDATE minerops_site_agents AS agent SET site_id = resolved.resolved_site_id FROM resolved_unassigned_agents AS resolved WHERE agent.agent_id = resolved.agent_id AND agent.site_id IS NULL AND resolved.resolved_site_id IS NOT NULL """ ) ) connection.execute( sa.text( """ UPDATE minerops_site_agents AS agent SET agent_zone_id = zone.id FROM agent_zones AS zone WHERE agent.site_id = zone.site_id AND zone.is_enabled AND lower(btrim(zone.name)) = lower(:default_name) AND agent.agent_zone_id IS NULL """ ), {"default_name": DEFAULT_ZONE_NAME}, ) connection.execute( sa.text( """ UPDATE minerops_agent_snapshots AS snapshot SET agent_zone_id = agent.agent_zone_id FROM minerops_site_agents AS agent WHERE snapshot.agent_id = agent.agent_id AND snapshot.agent_zone_id IS NULL AND agent.agent_zone_id IS NOT NULL """ ) ) connection.execute( sa.text( """ UPDATE minerops_agent_discoveries AS discovery SET agent_zone_id = agent.agent_zone_id FROM minerops_site_agents AS agent WHERE discovery.agent_id = agent.agent_id AND discovery.agent_zone_id IS NULL AND agent.agent_zone_id IS NOT NULL """ ) ) def downgrade() -> None: # There is no durable, trustworthy way to distinguish values assigned here # from assignments made by an operator after upgrade. Refuse instead of # erasing potentially explicit production routing or historical attribution. raise RuntimeError( "Cannot downgrade 0018 automatically: legacy Site/Zone routing and " "snapshot/discovery attribution may now be production data. Restore the " "pre-upgrade database backup for rollback; no data was changed." )