Files
minerops/backend/alembic/versions/0018_legacy_agent_routing.py
T

260 lines
9.7 KiB
Python

"""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."
)