Skip to content

Commit b34d7cf

Browse files
fix: reactivate disabled backend servers from the UI + v1.6.3 (Issue #24)
A backend server toggled OFF (is_active=false) vanished from the UI with no way to reactivate it: GET /api/backends honored include_inactive for backends but the server sub-queries hardcoded 'AND is_active = TRUE'. - get_backends: server sub-queries now honor include_inactive (default callers unchanged); added last_config_status to the server payload so the UI can tell a DISABLED server (re-enableable) from a DELETION (pending delete). - toggle_server: persists an entity snapshot so an Apply-Management Reject rolls back is_active (previously left the server stuck disabled). - BackendServers.js: requests include_inactive, shows disabled servers with the ON/OFF switch + an 'Inactive' tag, hides only DELETION-pending servers, and keeps soft-deleted BACKENDS hidden (so include_inactive doesn't resurface them). - Config generation unchanged: disabled servers stay '# DISABLED:' comments and convert back to live lines when re-enabled. Startup migration hardening (multi-replica / rolling-deploy safety): create_essential_tables fails fast on lock contention and retries; run_all_migrations is serialized by a session advisory lock and gated by a schema_migrations version marker, so an already-current schema is skipped instead of issuing lock-heavy DDL that a serving replica's traffic could block at startup. Idempotent and fail-open. Version reported consistently across all layers (version.json, backend fallback, frontend package) -> 1.6.3.
1 parent c2ea424 commit b34d7cf

8 files changed

Lines changed: 233 additions & 39 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2367,6 +2367,7 @@ Developed with ❤️ for the HAProxy community
23672367

23682368
## Release Notes
23692369

2370+
- **v1.6.3** (2026-06-01) — Bugfix: a backend **server** toggled OFF (`is_active=false`) disappeared from the UI with no way to reactivate it. `GET /api/backends` now honors `include_inactive` for servers (previously only backends), so disabled servers stay visible with an OFF switch + "Inactive" tag and can be re-enabled; soft-deleted (pending-delete) servers stay hidden. A Reject of a server toggle now correctly rolls back `is_active` (entity snapshot). Startup migrations are hardened for multiple replicas / rolling deploys (advisory lock + schema-version gate, so an already-current schema isn't re-migrated under a serving peer's load). Version is reported consistently across all layers. No schema changes; config generation unchanged (disabled servers stay commented out).
23702371
- **v1.6.2** (2026-05-30) — Bugfixes: (1) agents (which authenticate with their `X-API-Key` token) could not reach `GET /api/clusters` / `/api/clusters/{id}` after the v1.5.x cluster-read hardening, breaking agent assignment ("401: Authorization header missing"); these endpoints now accept either a user JWT or an agent token (anonymous access is still rejected). (2) The uninstall-script generator returned 400 for macOS agents (which report platform `darwin`); it now normalizes the platform the same way the install generator does. No UI or schema changes.
23712372
- **v1.6.1** (2026-05-21) — Security patch: bump `axios` to 1.16.x (prototype-pollution hardening, header-injection fix, keep-alive memory leak fix) and `fast-uri` to 3.1.2 (GHSA-v39h-62p7-jpjc). No functional changes.
23722373

backend/database/migrations.py

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,62 @@ async def ensure_agents_table():
5050
await conn.execute("ALTER TYPE config_status ADD VALUE IF NOT EXISTS 'DELETION';")
5151
logger.info("Ensured REJECTED and DELETION values exist in config_status enum.")
5252

53-
# First, create essential tables if they don't exist
54-
await create_essential_tables(conn)
55-
53+
# First, create essential tables if they don't exist.
54+
#
55+
# Rolling-restart resilience: create_essential_tables() runs idempotent
56+
# CREATE ... IF NOT EXISTS statements on every startup. Its CREATE INDEX
57+
# block needs a SHARE lock that conflicts with concurrent writes (e.g.
58+
# agent heartbeats updating backend_servers/agents). During a redeploy a
59+
# writer can hold that lock, so the DDL blocked for the full 60s
60+
# command_timeout -> TimeoutError -> startup crash -> crash-loop.
61+
#
62+
# Fix: fail fast on locks (lock_timeout), retry briefly, and on
63+
# persistent contention SKIP the idempotent bootstrap and continue — on
64+
# an established DB the objects already exist; a fresh DB has no writers
65+
# so the first attempt always succeeds. lock_timeout is scoped to this
66+
# call and RESET afterwards, so every other migration below keeps its
67+
# original (wait-indefinitely) behavior. Non-lock errors still propagate
68+
# (genuine schema problems must NOT be masked).
69+
import asyncio as _asyncio
70+
_lock_excs = (_asyncio.TimeoutError,)
71+
try:
72+
import asyncpg as _asyncpg
73+
_lock_excs = _lock_excs + (
74+
_asyncpg.exceptions.LockNotAvailableError,
75+
_asyncpg.exceptions.QueryCanceledError,
76+
)
77+
except Exception:
78+
pass
79+
try:
80+
await conn.execute("SET lock_timeout = '10s'")
81+
except Exception:
82+
pass
83+
try:
84+
for _attempt in range(1, 4):
85+
try:
86+
await create_essential_tables(conn)
87+
break
88+
except _lock_excs as _lock_err:
89+
if _attempt < 3:
90+
logger.warning(
91+
f"create_essential_tables: lock contention "
92+
f"(attempt {_attempt}/3), retrying in 3s "
93+
f"({type(_lock_err).__name__})"
94+
)
95+
await _asyncio.sleep(3)
96+
else:
97+
logger.warning(
98+
"create_essential_tables: persistent lock contention; "
99+
"skipping idempotent schema bootstrap and continuing "
100+
"startup (objects already exist on an established DB) "
101+
f"({type(_lock_err).__name__})"
102+
)
103+
finally:
104+
try:
105+
await conn.execute("RESET lock_timeout")
106+
except Exception:
107+
pass
108+
56109
# Ensure status column exists in config_versions table
57110
status_column_exists = await conn.fetchval("""
58111
SELECT 1 FROM information_schema.columns
@@ -1639,13 +1692,105 @@ async def ensure_agent_activity_logs_table():
16391692
await close_database_connection(conn)
16401693
# Don't raise - this is not critical for system operation
16411694

1695+
# Schema-version gate for the migration runner.
1696+
#
1697+
# >>> BUMP THIS whenever you add/modify ANY step in _run_all_migrations_inner()
1698+
# >>> that changes the schema (table/column/index/constraint) OR seeded/role data
1699+
# >>> (e.g. update_system_roles_to_enterprise_rbac). Otherwise the new step will
1700+
# >>> NOT run on databases already marked at the current version.
1701+
#
1702+
# When the DB already records >= this version, run_all_migrations() skips the
1703+
# whole (lock-heavy) idempotent sequence, so redeploys/scale-ups issue NO DDL and
1704+
# a concurrently-serving replica's traffic cannot block ALTER / CREATE INDEX (the
1705+
# rolling-deploy startup crash that motivated this gate).
1706+
#
1707+
# Backward compatibility (the product runs at many versions across companies):
1708+
# - First start on this code: no marker -> applied_version is NULL -> the FULL
1709+
# sequence runs (upgrades any prior version), THEN the marker is written. So
1710+
# upgrading from any older version is unaffected.
1711+
# - The marker is written ONLY after _run_all_migrations_inner() completes with
1712+
# no exception, so an interrupted/failed migration never marks an incomplete
1713+
# schema as done — the next start retries.
1714+
# - Behavior change vs the historical "re-run every idempotent ensure_* on every
1715+
# start": once marked, same-version restarts no longer re-run (and therefore no
1716+
# longer auto-repair manual drift). To force a re-run, bump SCHEMA_VERSION or
1717+
# delete the schema_migrations row.
1718+
SCHEMA_VERSION = 1
1719+
1720+
16421721
async def run_all_migrations():
1643-
"""Run all database migrations"""
1722+
"""Run all database migrations.
1723+
1724+
Hardened for multiple backend replicas / rolling deploys:
1725+
- A session-level advisory lock serializes the run so only one pod migrates
1726+
at a time (others wait, then hit the version gate and skip). It is
1727+
session-scoped, so it auto-releases if a pod dies mid-migration.
1728+
- A schema-version marker (schema_migrations) gates the run: when the DB is
1729+
already at SCHEMA_VERSION the whole idempotent sequence is skipped, so no
1730+
DDL is issued and a serving replica's traffic can't block it.
1731+
If the advisory lock or marker can't be used, we fall back to running the
1732+
(idempotent) migrations rather than crashing startup.
1733+
"""
16441734
logger.info("Starting database migrations...")
1645-
1735+
MIGRATION_ADVISORY_LOCK_KEY = 1836016242 # single-key advisory space ("migr"); distinct from the (ns,id) locks used elsewhere
1736+
lock_conn = None
1737+
lock_acquired = False
1738+
try:
1739+
lock_conn = await get_database_connection()
1740+
try:
1741+
await lock_conn.execute("SELECT pg_advisory_lock($1)", MIGRATION_ADVISORY_LOCK_KEY)
1742+
lock_acquired = True
1743+
logger.info("Acquired migration advisory lock (migrations serialized across pods)")
1744+
except Exception as _lock_e:
1745+
logger.warning(f"Could not acquire migration advisory lock; proceeding (migrations are idempotent): {_lock_e}")
1746+
1747+
# Schema-version gate: skip the lock-heavy sequence if the DB is current.
1748+
applied_version = None
1749+
try:
1750+
await lock_conn.execute("""
1751+
CREATE TABLE IF NOT EXISTS schema_migrations (
1752+
id INTEGER PRIMARY KEY DEFAULT 1,
1753+
version INTEGER NOT NULL,
1754+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1755+
CONSTRAINT schema_migrations_singleton CHECK (id = 1)
1756+
)
1757+
""")
1758+
applied_version = await lock_conn.fetchval("SELECT version FROM schema_migrations WHERE id = 1")
1759+
except Exception as _mk_e:
1760+
logger.warning(f"schema_migrations marker unavailable; running full migrations: {_mk_e}")
1761+
applied_version = None
1762+
1763+
if applied_version is not None and applied_version >= SCHEMA_VERSION:
1764+
logger.info(f"Schema already at version {applied_version} (>= {SCHEMA_VERSION}); skipping migration run.")
1765+
return
1766+
1767+
await _run_all_migrations_inner()
1768+
1769+
try:
1770+
await lock_conn.execute("""
1771+
INSERT INTO schema_migrations (id, version, applied_at)
1772+
VALUES (1, $1, CURRENT_TIMESTAMP)
1773+
ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, applied_at = EXCLUDED.applied_at
1774+
""", SCHEMA_VERSION)
1775+
logger.info(f"Recorded schema version {SCHEMA_VERSION} in schema_migrations.")
1776+
except Exception as _wr_e:
1777+
logger.warning(f"Could not record schema version marker (migrations still applied): {_wr_e}")
1778+
finally:
1779+
if lock_acquired and lock_conn is not None:
1780+
try:
1781+
await lock_conn.execute("SELECT pg_advisory_unlock($1)", MIGRATION_ADVISORY_LOCK_KEY)
1782+
except Exception:
1783+
pass
1784+
if lock_conn is not None:
1785+
await close_database_connection(lock_conn)
1786+
1787+
1788+
async def _run_all_migrations_inner():
1789+
"""The full idempotent migration sequence. Runs under the migration advisory
1790+
lock and is gated by the schema-version marker in run_all_migrations()."""
16461791
# First, ensure basic database schema exists
16471792
await run_init_sql()
1648-
1793+
16491794
# Then run additional migrations
16501795
await ensure_agents_table()
16511796
await ensure_config_versions_metadata_column()

backend/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import asyncio
99
from datetime import datetime, timedelta
1010

11-
_version_info = {"version": "1.6.0", "releaseName": "Multi-Factor Authentication (MFA)", "releaseDate": "2026-05-18"}
11+
_version_info = {"version": "1.6.3", "releaseName": "Inactive server reactivation fix (issue #24)", "releaseDate": "2026-06-01"}
1212
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
1313
try:
1414
with open(_vpath) as _vf:

backend/routers/backend.py

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -333,31 +333,38 @@ async def get_backends(
333333
""")
334334

335335
result = []
336+
# Issue #24: servers must honor include_inactive exactly like the backend
337+
# queries above. Pre-fix these sub-queries hardcoded `is_active = TRUE`
338+
# (added in f34a6ee to hide soft-deleted entities), so a server toggled
339+
# OFF (is_active=false) vanished from the UI with no way to reactivate it.
340+
# Default callers (include_inactive=false) keep the is_active filter →
341+
# byte-identical behavior; include_inactive=true now also returns inactive
342+
# (disabled / soft-deleted) servers. last_config_status is selected so the
343+
# frontend can tell DISABLED (re-enableable) from DELETION (pending delete).
344+
server_active_filter = "" if include_inactive else "AND is_active = TRUE"
336345
for backend in backends:
337-
# Get servers for this backend with cluster_id (ONLY show active servers)
338-
# CRITICAL FIX: Add is_active = TRUE filter to prevent soft-deleted servers from appearing
339346
if cluster_id:
340-
servers = await conn.fetch("""
347+
servers = await conn.fetch(f"""
341348
SELECT id, server_name, server_address, server_port, weight, maxconn,
342349
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
343350
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
344351
cookie_value, inter, fall, rise,
345-
is_active, cluster_id,
352+
is_active, cluster_id, last_config_status,
346353
haproxy_status, haproxy_status_updated_at, backend_name
347-
FROM backend_servers
348-
WHERE backend_name = $1 AND cluster_id = $2 AND is_active = TRUE ORDER BY server_name
354+
FROM backend_servers
355+
WHERE backend_name = $1 AND cluster_id = $2 {server_active_filter} ORDER BY server_name
349356
""", backend["name"], cluster_id)
350357
else:
351-
servers = await conn.fetch("""
358+
servers = await conn.fetch(f"""
352359
SELECT id, server_name, server_address, server_port, weight, maxconn,
353360
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
354361
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
355362
cookie_value, inter, fall, rise,
356-
is_active, cluster_id,
363+
is_active, cluster_id, last_config_status,
357364
haproxy_status, haproxy_status_updated_at, backend_name
358-
FROM backend_servers
359-
WHERE backend_name = $1 AND is_active = TRUE ORDER BY server_name
360-
""", backend["name"])
365+
FROM backend_servers
366+
WHERE backend_name = $1 {server_active_filter} ORDER BY server_name
367+
""", backend["name"])
361368

362369
# Prepare server list with real-time HAProxy status from agents
363370
server_list = []
@@ -413,6 +420,7 @@ async def get_backends(
413420
"fall": s.get("fall"),
414421
"rise": s.get("rise"),
415422
"is_active": s["is_active"],
423+
"last_config_status": s.get("last_config_status") or "APPLIED", # Issue #24: lets UI distinguish DISABLED (re-enableable) from DELETION
416424
"status": server_status,
417425
"status_age_minutes": status_age_minutes,
418426
"last_status_update": s.get("haproxy_status_updated_at").isoformat().replace('+00:00', 'Z') if s.get("haproxy_status_updated_at") else None,
@@ -1909,12 +1917,12 @@ async def toggle_server(server_id: int, request: Request, authorization: str = H
19091917

19101918
conn = await get_database_connection()
19111919

1912-
# Get server info
1920+
# Get server info. Issue #24: fetch the FULL row (not just 5 columns) so we
1921+
# can snapshot the pre-toggle state for reject-rollback (see config version below).
19131922
server = await conn.fetchrow("""
1914-
SELECT id, server_name, backend_name, is_active, cluster_id
1915-
FROM backend_servers WHERE id = $1
1923+
SELECT * FROM backend_servers WHERE id = $1
19161924
""", server_id)
1917-
1925+
19181926
if not server:
19191927
await close_database_connection(conn)
19201928
raise HTTPException(status_code=404, detail="Server not found")
@@ -1975,22 +1983,45 @@ async def toggle_server(server_id: int, request: Request, authorization: str = H
19751983

19761984
# Generate new HAProxy config (after database commit)
19771985
config_content = await generate_haproxy_config_for_cluster(cluster_id)
1978-
1986+
19791987
# Create new config version
19801988
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
19811989
version_name = f"server-{server_id}-toggle-{int(time.time())}"
1982-
1990+
19831991
# Get system admin user ID for created_by
19841992
conn2 = await get_database_connection()
19851993
admin_user_id = await conn2.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
1986-
1994+
1995+
# Issue #24: persist an entity snapshot so a Reject of this toggle
1996+
# rolls back is_active to its pre-toggle value. Pre-fix the toggle's
1997+
# config version carried NO metadata, so reject only reset
1998+
# last_config_status and the server stayed disabled (out of sync with
1999+
# the still-active live config). Mirrors the server-edit snapshot path;
2000+
# reject's rollback_entity_from_snapshot('server') restores is_active.
2001+
import json
2002+
from utils.entity_snapshot import save_entity_snapshot
2003+
entity_snapshot_metadata = await save_entity_snapshot(
2004+
conn=conn2,
2005+
entity_type="server",
2006+
entity_id=server_id,
2007+
old_values=dict(server), # full pre-toggle row
2008+
new_values={"is_active": new_status},
2009+
operation="UPDATE",
2010+
)
2011+
old_config = await conn2.fetchval("""
2012+
SELECT config_content FROM config_versions
2013+
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
2014+
ORDER BY created_at DESC LIMIT 1
2015+
""", cluster_id)
2016+
metadata = {"pre_apply_snapshot": old_config or "", **entity_snapshot_metadata}
2017+
19872018
# Create PENDING config version
19882019
config_version_id = await conn2.fetchval("""
1989-
INSERT INTO config_versions
1990-
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
1991-
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
2020+
INSERT INTO config_versions
2021+
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
2022+
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
19922023
RETURNING id
1993-
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
2024+
""", cluster_id, version_name, config_content, config_hash, admin_user_id, json.dumps(metadata))
19942025

19952026
logger.error(f"SERVER TOGGLE DEBUG: Created PENDING config version {version_name} for cluster {cluster_id}")
19962027

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "haproxy-openmanager-frontend",
3-
"version": "1.6.2",
3+
"version": "1.6.3",
44
"description": "HAProxy Load Balancer Management UI",
55
"license": "AGPL-3.0-or-later",
66
"dependencies": {

0 commit comments

Comments
 (0)