feat(agent): add durable existing-agent credential transitions
This commit is contained in:
1 parent
abf5654a25
commit
3cfb0d1abe
33 files changed
+3828
-54
No files matched your search
@@ -0,0 +1,259 @@
|
||||
"""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."
|
||||
)
|
||||
Reference in new issue
Block a user