-
Notifications
You must be signed in to change notification settings - Fork 0
fix(proxmox): make host registration idempotent on name #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| """dedupe proxmox_hosts, then enforce a unique name | ||
|
|
||
| Until now nothing stopped a second host row under an existing name, and the | ||
| deploy bundle re-POSTs the same name on every scenario run — so field | ||
| databases carry one duplicate per re-run. The duplicates are not inert: | ||
| ``deployments.target_host_id`` points at whichever one existed when the | ||
| deployment was created. | ||
|
|
||
| Collapsing keys on ``added_at``: the earliest row under a name is the one the | ||
| first deploy registered, so it is the row most likely to be referenced and the | ||
| one whose id callers may have recorded. Deployments on the later duplicates are | ||
| repointed at it before those rows are deleted, so nothing is left dangling. | ||
|
|
||
| Revision ID: 0002_proxmox_host_unique_name | ||
| Revises: 0001_v1_initial | ||
| Create Date: 2026-08-10 | ||
| """ | ||
| import logging | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision = '0002_proxmox_host_unique_name' | ||
| down_revision = '0001_v1_initial' | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
| log = logging.getLogger("alembic.runtime.migration") | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| conn = op.get_bind() | ||
|
|
||
| # added_at first, id as a deterministic tie-break for rows registered | ||
| # within the same clock tick. | ||
| rows = conn.execute( | ||
| sa.text("SELECT id, name FROM proxmox_hosts ORDER BY name, added_at, id") | ||
| ).fetchall() | ||
|
|
||
| keepers: dict[str, str] = {} | ||
| for host_id, name in rows: | ||
| keeper_id = keepers.setdefault(name, host_id) | ||
| if keeper_id == host_id: | ||
| continue | ||
|
|
||
| moved = conn.execute( | ||
| sa.text( | ||
| "UPDATE deployments SET target_host_id = :keeper" | ||
| " WHERE target_host_id = :loser" | ||
| ), | ||
| {"keeper": keeper_id, "loser": host_id}, | ||
| ).rowcount | ||
| conn.execute( | ||
| sa.text("DELETE FROM proxmox_hosts WHERE id = :loser"), | ||
| {"loser": host_id}, | ||
| ) | ||
| log.info( | ||
| "proxmox_hosts: collapsed duplicate %r (%s) into %s, " | ||
| "repointed %d deployment(s)", | ||
| name, host_id, keeper_id, moved, | ||
| ) | ||
|
|
||
| with op.batch_alter_table("proxmox_hosts") as batch: | ||
| batch.create_unique_constraint("uq_proxmox_host_name", ["name"]) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| # Only the constraint is reversible; the collapsed rows are gone for good. | ||
| with op.batch_alter_table("proxmox_hosts") as batch: | ||
| batch.drop_constraint("uq_proxmox_host_name", type_="unique") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| from datetime import datetime, timezone | ||
|
|
||
| import httpx | ||
| from fastapi import APIRouter, Depends, status | ||
| from fastapi import APIRouter, Depends, Response, status | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
|
|
@@ -68,11 +68,57 @@ async def list_hosts( | |
|
|
||
|
|
||
| @router.post( | ||
| "/hosts", response_model=HostOut, status_code=status.HTTP_201_CREATED | ||
| "/hosts", | ||
| response_model=HostOut, | ||
| status_code=status.HTTP_201_CREATED, | ||
| responses={ | ||
| 200: { | ||
| "model": HostOut, | ||
| "description": "Host already registered under this name; updated in place.", | ||
| } | ||
| }, | ||
| ) | ||
| async def create_host( | ||
| payload: HostIn, session: AsyncSession = Depends(_session) | ||
| payload: HostIn, | ||
| response: Response, | ||
| session: AsyncSession = Depends(_session), | ||
| ): | ||
| """Register a Proxmox host, or refresh the one already under that name. | ||
|
|
||
| The deploy bundle POSTs this on every scenario run, so it has to be | ||
| idempotent. Re-registering keeps the existing row's id — ``deployments`` | ||
| reference it by FK — and returns 200 instead of 201. Credentials are part | ||
| of what gets refreshed: a rotated PVE token reaches the backend on the next | ||
| deploy rather than leaving it authenticating with a stale one. | ||
| """ | ||
| overrides_json = ( | ||
| json.dumps(payload.protected_vmids_override) | ||
| if payload.protected_vmids_override | ||
| else None | ||
| ) | ||
|
|
||
| existing = ( | ||
| await session.execute( | ||
| select(ProxmoxHost).where(ProxmoxHost.name == payload.name) | ||
| ) | ||
| ).scalar_one_or_none() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two scenario runs register the same previously unseen name concurrently, both requests can complete this query before either inserts; each then takes the create path, and the second commit violates Useful? React with 👍 / 👎. |
||
| if existing is not None: | ||
| existing.api_url = str(payload.api_url) | ||
| existing.node_name = payload.node_name | ||
| existing.token_ref = payload.token_ref | ||
| existing.token_scope = payload.token_scope | ||
| existing.default_bridge = payload.default_bridge | ||
| existing.protected_vmids_override_json = overrides_json | ||
| # added_at deliberately untouched: it records when this host was first | ||
| # registered, and the dedupe migration keys on it. | ||
| await session.commit() | ||
| await session.refresh(existing) | ||
| log.info( | ||
| "proxmox_host_reregistered", host_id=existing.id, name=existing.name | ||
| ) | ||
| response.status_code = status.HTTP_200_OK | ||
| return _row_to_out(existing) | ||
|
|
||
| row = ProxmoxHost( | ||
| id=uuid.uuid4().hex[:16], | ||
| name=payload.name, | ||
|
|
@@ -81,11 +127,7 @@ async def create_host( | |
| token_ref=payload.token_ref, | ||
| token_scope=payload.token_scope, | ||
| default_bridge=payload.default_bridge, | ||
| protected_vmids_override_json=( | ||
| json.dumps(payload.protected_vmids_override) | ||
| if payload.protected_vmids_override | ||
| else None | ||
| ), | ||
| protected_vmids_override_json=overrides_json, | ||
| ) | ||
| session.add(row) | ||
| await session.commit() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When duplicate registrations contain a rotated token or updated URL/node, sorting oldest-first and retaining that entire row permanently deletes the newest configuration while repointing every deployment to the stale credentials. This can make existing deployments and health checks fail immediately after upgrade, before another deploy bundle happens to re-register the host. Keep the oldest ID if identity preservation is required, but copy the newest duplicate's mutable host fields onto it before deleting the later rows.
Useful? React with 👍 / 👎.