Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions alembic/versions/0002_proxmox_host_unique_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""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()
Comment on lines +36 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the newest host configuration during deduplication

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 👍 / 👎.


groups: dict[str, list[str]] = {}
for host_id, name in rows:
groups.setdefault(name, []).append(host_id)

for name, ids in groups.items():
if len(ids) == 1:
continue
keeper_id, *loser_ids = ids

# The id is the oldest row's, because deployments reference it. The
# connection details are the NEWEST row's: duplicates accumulated one
# per scenario re-run, so the last registration holds the credentials
# in force. Keeping the first row wholesale would resurrect a token
# that may since have been rotated away, and every deployment
# repointed at it would start failing auth the moment this ran.
conn.execute(
sa.text(
"UPDATE proxmox_hosts SET"
" api_url = latest.api_url,"
" node_name = latest.node_name,"
" token_ref = latest.token_ref,"
" token_scope = latest.token_scope,"
" default_bridge = latest.default_bridge,"
" protected_vmids_override_json ="
" latest.protected_vmids_override_json,"
" last_health_check_json = latest.last_health_check_json"
" FROM (SELECT * FROM proxmox_hosts WHERE id = :newest) AS latest"
" WHERE proxmox_hosts.id = :keeper"
),
{"newest": loser_ids[-1], "keeper": keeper_id},
)

for loser_id in loser_ids:
moved = conn.execute(
sa.text(
"UPDATE deployments SET target_host_id = :keeper"
" WHERE target_host_id = :loser"
),
{"keeper": keeper_id, "loser": loser_id},
).rowcount
conn.execute(
sa.text("DELETE FROM proxmox_hosts WHERE id = :loser"),
{"loser": loser_id},
)
log.info(
"proxmox_hosts: collapsed duplicate %r (%s) into %s, "
"repointed %d deployment(s)",
name, loser_id, keeper_id, moved,
)
log.info(
"proxmox_hosts: %r kept id %s with the connection details from %s",
name, keeper_id, loser_ids[-1],
)

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")
5 changes: 5 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class ProxmoxHost(Base):
protected_vmids_override_json: Mapped[str | None] = mapped_column(Text)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
last_health_check_json: Mapped[str | None] = mapped_column(Text)
# A host is identified by its name: the deploy bundle re-POSTs the same
# name on every scenario run, and deployments.target_host_id is a FK here,
# so a second row per re-run would strand earlier deployments on a host
# nobody updates. Uniqueness turns those re-runs into in-place updates.
__table_args__ = (UniqueConstraint("name", name="uq_proxmox_host_name"),)


class Project(Base):
Expand Down
85 changes: 76 additions & 9 deletions app/routes/v1/proxmox/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.errors import AuthFailedError, Range42Error
Expand Down Expand Up @@ -47,6 +48,26 @@ def _row_to_out(row: ProxmoxHost) -> HostOut:
)


async def _find_host_by_name(
session: AsyncSession, name: str
) -> ProxmoxHost | None:
return (
await session.execute(
select(ProxmoxHost).where(ProxmoxHost.name == name)
)
).scalar_one_or_none()


def _refresh_host(row: ProxmoxHost, payload: HostIn, overrides_json: str | None) -> None:
"""Carry a re-registration onto an existing row, id and added_at intact."""
row.api_url = str(payload.api_url)
row.node_name = payload.node_name
row.token_ref = payload.token_ref
row.token_scope = payload.token_scope
row.default_bridge = payload.default_bridge
row.protected_vmids_override_json = overrides_json

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assigns all six columns unconditionally, so the upsert has PUT semantics against a payload where three fields carry schema defaults. Anything the caller omits is reset rather than left alone.

Confirmed against the schema on this branch:

>>> p = HostIn(name='x', api_url='https://h:8006', node_name='n', token_ref='t')
>>> sorted(p.model_fields_set)
['api_url', 'name', 'node_name', 'token_ref']
>>> p.default_bridge, p.protected_vmids_override
('vmbr0', None)

Those four are exactly what the deploy bundle sends (range42-playbooks bundles/admin/software.install.deployer_api_backend/main.yml:495). So every scenario re-run now writes token_scope = NULL, default_bridge = 'vmbr0', protected_vmids_override_json = NULL.

Concrete case: a host registered by hand with default_bridge: vmbr142 and protected_vmids_override: [[200, 250]]. The next scenario run silently resets the bridge to vmbr0 and drops the 200–250 guard, and filter_safe_vmids will then hand 200–250 to a mass delete as safe. With no PUT/PATCH on hosts (only this POST and the DELETE at line 163) and the UI read-only, re-POSTing by hand is the only recovery — and the following deploy wipes it again.

Worth being explicit that this is not the VMID 100/101 case: _effective_ranges() appends overrides onto DEFAULT_PROTECTED_RANGES (app/core/vmid_guard.py:40-45), so clearing an override narrows protection back to the defaults and can never unprotect pmg01/zbx01. That is what keeps this non-blocking.

Pydantic 2.13.4 is already in use, so restricting the write to what was actually supplied is a small change:

Suggested change
row.protected_vmids_override_json = overrides_json
def _refresh_host(row: ProxmoxHost, payload: HostIn, overrides_json: str | None) -> None:
"""Carry a re-registration onto an existing row, id and added_at intact.
Only fields the caller actually sent are written: the deploy bundle posts
four of them, and a blind assign would reset default_bridge, token_scope
and the protected-VMID override to their schema defaults on every re-run.
"""
sent = payload.model_fields_set
if "api_url" in sent:
row.api_url = str(payload.api_url)
if "node_name" in sent:
row.node_name = payload.node_name
if "token_ref" in sent:
row.token_ref = payload.token_ref
if "token_scope" in sent:
row.token_scope = payload.token_scope
if "default_bridge" in sent:
row.default_bridge = payload.default_bridge
if "protected_vmids_override" in sent:
row.protected_vmids_override_json = overrides_json

If you'd rather keep full-replace semantics, that works too — but then the bundle should send the full record, otherwise the re-seed is the thing destroying it.


Generated by Claude Code



@router.get("/hosts", response_model=Page[HostOut])
async def list_hosts(
session: AsyncSession = Depends(_session),
Expand All @@ -68,11 +89,49 @@ 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
)

async def _update_in_place(row: ProxmoxHost) -> HostOut:
# added_at deliberately untouched: it records when this host was first
# registered, and the dedupe migration keys on it.
_refresh_host(row, payload, overrides_json)
await session.commit()
await session.refresh(row)
log.info("proxmox_host_reregistered", host_id=row.id, name=row.name)
response.status_code = status.HTTP_200_OK
return _row_to_out(row)

existing = await _find_host_by_name(session, payload.name)
if existing is not None:
return await _update_in_place(existing)

row = ProxmoxHost(
id=uuid.uuid4().hex[:16],
name=payload.name,
Expand All @@ -81,14 +140,22 @@ 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()
try:
await session.commit()
except IntegrityError:
# Lost the race: another request registered this name between the
# lookup above and this commit. Recover into the update path instead
# of surfacing a 500 — the caller asked for a registration and one
# now exists, which is the outcome they wanted.
await session.rollback()
winner = await _find_host_by_name(session, payload.name)
if winner is None:
raise
log.info("proxmox_host_register_race", name=payload.name, host_id=winner.id)
return await _update_in_place(winner)
await session.refresh(row)
return _row_to_out(row)

Expand Down
11 changes: 11 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3507,6 +3507,7 @@
"v1 proxmox"
],
"summary": "Create Host",
"description": "Register a Proxmox host, or refresh the one already under that name.\n\nThe deploy bundle POSTs this on every scenario run, so it has to be\nidempotent. Re-registering keeps the existing row's id \u2014 ``deployments``\nreference it by FK \u2014 and returns 200 instead of 201. Credentials are part\nof what gets refreshed: a rotated PVE token reaches the backend on the next\ndeploy rather than leaving it authenticating with a stale one.",
"operationId": "create_host_v1_proxmox_hosts_post",
"requestBody": {
"required": true,
Expand Down Expand Up @@ -3538,6 +3539,16 @@
}
}
}
},
"200": {
"description": "Host already registered under this name; updated in place.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostOut"
}
}
}
}
}
}
Expand Down
Loading
Loading