Compare commits

...

3 Commits

Author SHA1 Message Date
taylanbakircioglu 60f4fa71ed ci: read releaseName from backend/version.json in the release step (v1.8.7 follow-up)
The version.json single-source move (v1.8.7) updated the version READ step but
missed the create-release step, which still ran jq against the deleted repo-root
version.json and failed the workflow. Point it at backend/version.json. This
release step is public-only (GitHub Releases), so it has no corporate counterpart.
2026-07-09 16:35:10 +03:00
taylanbakircioglu 97b2452bd2 fix(version): single-source version reporting to stop UI version drift (v1.8.7)
The version shown in the UI (backend-sourced via /api/version) could lag the
real release. The canonical version lived at repo-root version.json, but the
backend image is built with context ./backend, so that file did not reach the
container unless a pipeline staged it; the backend then fell back to a hardcoded
constant in main.py that had to be bumped by hand and had drifted (it reported
1.8.4 after 1.8.5 and 1.8.6 shipped).

- version.json moves to backend/version.json (single source of truth), now
  committed and co-located with main.py, so COPY . . bakes it into every image
  directly - correct version in every deployment, no pipeline staging required.
- backend/main.py reads the co-located file; its in-code fallback is no longer a
  real version ("unknown") so it can never silently drift again.
- docker-build.yml reads backend/version.json and drops the staging step;
  docker-compose.localtest drops the stale root mount.
- backend/tests/test_version_consistency.py fails the build if main.py hardcodes
  a real version, if the canonical file is missing/invalid, or if
  frontend/package.json drifts from it.

Bumped to 1.8.7. No functional, schema, API, or agent change. Full suite green.
2026-07-09 16:06:17 +03:00
taylanbakircioglu 23257b02cf perf(api): opt-in uvicorn workers + heartbeat query consolidation (v1.8.6, Issue #35)
A user running the API on a 2-core/4GB host reported slow-feeling API
responses (Issue #35 follow-up). Review of the hot paths found no
pathological defect; the dominant factors are the single uvicorn worker
(one core serves all requests) and the constant agent-poll baseline
(4 requests per agent every 30s). Two zero-risk improvements:

- backend/Dockerfile: CMD now honors UVICORN_WORKERS, falling back to
  WEB_CONCURRENCY and then 1. Flagless uvicorn natively honors
  WEB_CONCURRENCY, so the fallback keeps any deployment that relied on it
  byte-for-byte compatible; with the final default of 1 worker uvicorn runs
  in-process exactly as before. >1 enables the multiprocess supervisor so
  multi-core hosts can use all cores. Background tasks are already
  multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as exercised
  by the k8s HPA deployment (2-10 replicas). `exec` keeps uvicorn as PID 1
  (clean SIGTERM, verified ~1s docker stop with 2 workers).
  docker-compose.yml passes UVICORN_WORKERS through as empty-when-unset so a
  user-set WEB_CONCURRENCY is never overridden; .env.template documents it.

- routers/agent.py heartbeat (by-name endpoint): the agent's
  status/version/upgrade_status were read with three separate single-column
  SELECTs against the same row; now one SELECT. Identical values and None
  semantics (single consistent snapshot instead of three reads); saves two
  round-trips per heartbeat per agent every 30s. The legacy by-id heartbeat
  endpoint is untouched; the heartbeat API contract is unchanged for agents
  of every version.

- README: new "Performance Tuning" section (worker/replica scaling, and how
  to use the X-Response-Time header plus "Slow request detected" logs to
  pinpoint slow endpoints).

Verification: full backend suite in docker green (1063 passed, 151 skipped;
also re-run by the runtime image build); worker-count expansion matrix
(unset->1, UVICORN_WORKERS=2->2, WEB_CONCURRENCY=3->3, both->UVICORN_WORKERS,
empty->fallback) all correct; default run confirmed single-process with
uvicorn as PID 1 and healthy API; UVICORN_WORKERS=2 confirmed parent + 2
workers, healthy API, clean shutdown; live heartbeats verified for
register + existing-agent paths AND degraded agents (no stats socket /
haproxy stopped / garbage stats CSV / unknown backend in server_statuses):
all return 200, agent row updates correctly, zero backend errors.
No schema, API, or agent changes.
2026-07-06 02:27:39 +03:00
13 changed files with 136 additions and 38 deletions
+9
View File
@@ -59,6 +59,15 @@ AGENT_HEARTBEAT_TIMEOUT_SECONDS=15
# Config sync interval in seconds
AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
# ============================================================================
# BACKEND PERFORMANCE
# ============================================================================
# Number of uvicorn worker processes for the backend API (default: 1).
# On multi-core hosts, setting this to the core count (e.g. 2) lets the API
# use all cores. Safe to increase: background tasks are multi-replica safe
# (the k8s deployment already runs 2+ replicas via HPA).
UVICORN_WORKERS=1
# ============================================================================
# CORS CONFIGURATION
# ============================================================================
+7 -17
View File
@@ -21,27 +21,17 @@ jobs:
- name: read product version
id: prodversion
run: |
VERSION=$(jq -r .version version.json)
VERSION=$(jq -r .version backend/version.json)
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "Failed to read product version from version.json" >&2
echo "Failed to read product version from backend/version.json" >&2
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
# The backend image is built with `context: ./backend`, so the
# repo-root version.json is OUTSIDE the build context and never
# reaches the container. Backend `main.py` falls back to a
# compile-time constant when /app/version.json is missing, which
# caused a real production drift: a redeploy of the v1.5.2 tree
# silently still reported "v1.5.0" in `/api/version` because the
# constant in main.py had been bumped but the file was not
# available to read. Stage version.json into the backend
# context here so the canonical file IS shipped and the
# constant only serves as a defensive fallback. The staged file
# is gitignored to keep `git status` clean for developers.
- name: stage version.json into backend build context
run: cp version.json backend/version.json
# version.json now lives at backend/version.json (inside the ./backend build
# context), so `COPY . .` bakes it into the image directly — no staging step
# is needed and the backend reports the correct version in every deployment,
# not just this workflow's builds.
- name: set up qemu
uses: docker/setup-qemu-action@v3
@@ -91,7 +81,7 @@ jobs:
run: |
VERSION="${{ steps.prodversion.outputs.VERSION }}"
TAG="v${VERSION}"
RELEASE_NAME=$(jq -r '.releaseName // empty' version.json)
RELEASE_NAME=$(jq -r '.releaseName // empty' backend/version.json)
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists, skipping."
else
-5
View File
@@ -39,11 +39,6 @@ venv.bak/
*.sqlite
*.sqlite3
# Build-time staged version.json (CI `cp version.json backend/`).
# The canonical file lives at repo root; this path is a transient
# copy for the backend Docker build context.
backend/version.json
# IDE
.vscode/
.idea/
+16 -1
View File
@@ -1738,6 +1738,19 @@ Add new HAProxy clusters through the web interface or directly via API:
}
```
### Performance Tuning *(v1.8.6)*
The backend API defaults to a **single uvicorn worker process**, which uses one CPU core. Agents poll the API every 30 seconds (heartbeat, config, pending-requests, upgrade checks), so larger fleets add a constant baseline load. Two ways to scale:
- **Docker Compose — worker processes**: set `UVICORN_WORKERS` in your `.env` (default `1`). On a multi-core host, matching the core count (e.g. `UVICORN_WORKERS=2` on a 2-core machine) lets the API use all cores:
```bash
echo "UVICORN_WORKERS=2" >> .env && docker-compose up -d backend
```
- **Kubernetes/OpenShift — replicas**: the shipped manifests already include an HPA for the backend (2→10 replicas, `k8s/manifests/13-hpa.yaml`); raise `minReplicas`/`maxReplicas` as needed.
Both are safe: all background tasks (ACME completion, renewals, agent monitoring) are multi-replica safe by design (atomic claims via `FOR UPDATE SKIP LOCKED`, PostgreSQL advisory locks).
**Diagnosing slow requests**: every API response carries an `X-Response-Time` header, and the backend logs `Slow request detected` (WARNING) for any request taking longer than 1 second — check those log lines to pinpoint slow endpoints before tuning anything else.
## API Reference
@@ -2069,7 +2082,7 @@ haproxy-openmanager/
├── docker-compose.yml # Docker Compose configuration
├── docker-compose.localtest.yml # Local development/testing overrides
├── docker-compose.test.yml # Test environment
├── version.json # Application version metadata
├── backend/version.json # Application version metadata (single source of truth)
├── build-images.sh # Build Docker images
├── pytest.ini # Pytest configuration
├── README.md # This file
@@ -2415,6 +2428,8 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **v1.8.7** (2026-07-09) — **Version reporting single-source fix**: the version shown in the UI (backend-sourced via `/api/version`) could lag behind the real release. The canonical version lived in the repo-root `version.json`, but the backend image is built from the `./backend` context, so that file did not reach the container in every pipeline; the backend then fell back to a hardcoded constant in `main.py` that had to be bumped by hand and had drifted (it reported 1.8.4 after 1.8.5/1.8.6 shipped). The version now lives in a single file, `backend/version.json`, baked into every image automatically, and `main.py` no longer carries a real version literal (its fallback is a neutral "unknown"). A new test enforces that the version stays single-source and cannot drift. No functional or API change.
- **v1.8.6** (2026-07-06) — **Performance: opt-in API workers + heartbeat micro-optimization** (Issue #35 follow-up): the backend container can now run multiple uvicorn worker processes via the new `UVICORN_WORKERS` environment variable (default **1** — behavior unchanged unless you opt in), letting the API use all cores on multi-core hosts; background tasks were already multi-replica safe, as exercised by the Kubernetes HPA deployment. The agent heartbeat handler now reads the agent's `status`/`version`/`upgrade_status` in one query instead of three (one round-trip per heartbeat, per agent, every 30s). Added a *Performance Tuning* section to the README (worker/replica scaling and how to use the `X-Response-Time` header and `Slow request detected` logs to pinpoint slow endpoints). Zero-risk release: no schema, API, or agent changes; defaults preserve existing behavior exactly.
- **v1.8.5** (2026-07-03) — **ACME completion-task SQL fix** (Issue #35 follow-up): the background order-completion task (`complete_pending_acme_orders`, runs every 60s) died on **every cycle** with `syntax error at or near ")"` — an extra closing parenthesis introduced in v1.8.0's bounded DNS-01 retry claim query. Because that query is the task's first database call, **no background ACME work ran at all from v1.8.0 through v1.8.4**: orders were never claimed for finalize/download, the DNS-01 TXT record was never published (so DNS-01 with an automated provider such as Cloudflare could never validate), Site Wizard staged orders never left `wizard_staged`, and DNS-01 retry/TXT-cleanup never executed. The stray parenthesis is removed and a regression test now scans all ACME modules' SQL for unbalanced parentheses (the unit suite mocks the database, which is why a raw-SQL syntax error could slip through). One-line backend query fix; no schema, API, or agent changes — fully backward compatible.
- **v1.8.4** (2026-06-27) — **Agent installer self-kill fix** (Issue #31): the Linux/macOS agent installer could abort during "pre-installation cleanup" (terminal showed `Killing processes matching: haproxy-agent` then `Killed`) when the install script's own filename contained "haproxy-agent". The cleanup killed processes by matching the bare string "haproxy-agent" against full command lines, which also matched the running installer (and a `sudo`/PAM ancestor the self-exclusion did not cover), so the installer terminated itself. Cleanup now targets only the installed agent (the `$INSTALL_DIR/haproxy-agent` binary and the agent service), never the bare string, and the UI now names the downloaded scripts `install-agent-<platform>.sh` / `uninstall-agent-<platform>.sh`. Installer-only change; the running agent and its privilege model (it runs as root for HAProxy reload, config writes, keepalived, and self-upgrade) are unchanged.
- **v1.8.3** (2026-06-25) — **Agent heartbeat JSON fix** (Issue #31): a self-hosted agent could fail every heartbeat with `HTTP 400 Invalid JSON: Expecting property name enclosed in double quotes` when the system-info block it collects came back empty on an unusual host, leaving a stray comma in the hand-built heartbeat JSON. The agent script now substitutes a valid placeholder when that block is empty so it can no longer emit a stray comma, and the backend heartbeat endpoint now parses valid payloads as-is and, only when a body fails to parse, tolerates that specific malformed pattern (a leading or doubled comma) so an already-deployed agent recovers on its next heartbeat after this build is deployed. Backend + agent-script only; healthy agents of every version are byte-for-byte unaffected.
+10 -2
View File
@@ -37,5 +37,13 @@ USER appuser
# Expose port
EXPOSE 8000
# Run the application in production mode (without --reload)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application in production mode (without --reload).
# UVICORN_WORKERS (default 1) opts into multiple worker processes on multi-core
# hosts; with 1 worker uvicorn runs in-process, identical to the flagless CMD
# this replaces. Falls back to WEB_CONCURRENCY when UVICORN_WORKERS is unset
# because flagless uvicorn honored WEB_CONCURRENCY (uvicorn config.py) — this
# keeps any deployment that relied on it byte-for-byte compatible. Background
# tasks are multi-replica safe (FOR UPDATE SKIP LOCKED / advisory locks), as
# already exercised by the k8s HPA deployment. `exec` keeps uvicorn as PID 1
# so signal handling is unchanged.
CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-${WEB_CONCURRENCY:-1}}"]
+7 -3
View File
@@ -8,9 +8,13 @@ import redis
import asyncio
from datetime import datetime, timedelta
# Build/deploy marker for the v1.8.x (Issue #35, DNS-01) rollout — ensures the pipeline ships this commit's image.
_version_info = {"version": "1.8.4", "releaseName": "Agent installer self-kill fix", "releaseDate": "2026-06-27"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
# Single source of truth: backend/version.json, which sits next to this module and is baked into
# every image by `COPY . .` (build context ./backend) — no pipeline staging needed. The literal
# below is only a last-resort "file missing" marker; it is deliberately NOT a real version so it can
# never silently drift out of sync (this exact drift showed a stale version after v1.8.5/v1.8.6).
# Keep the canonical version ONLY in backend/version.json — test_version_consistency.py enforces it.
_version_info = {"version": "unknown", "releaseName": "unknown", "releaseDate": ""}
for _vpath in [os.path.join(os.path.dirname(__file__), "version.json"), "/app/version.json"]:
try:
with open(_vpath) as _vf:
_version_info = json.load(_vf)
+7 -3
View File
@@ -1695,9 +1695,13 @@ async def agent_heartbeat_by_name(
""", x_api_key)
# Check if agent was in upgrading status and version has changed
current_agent_status = await conn.fetchval("SELECT status FROM agents WHERE id = $1", agent_id)
current_agent_version = await conn.fetchval("SELECT version FROM agents WHERE id = $1", agent_id)
current_upgrade_status = await conn.fetchval("SELECT upgrade_status FROM agents WHERE id = $1", agent_id)
# (v1.8.6: one round-trip instead of three; row is None exactly when the
# per-column fetchvals would each have returned None)
current_agent_row = await conn.fetchrow(
"SELECT status, version, upgrade_status FROM agents WHERE id = $1", agent_id)
current_agent_status = current_agent_row['status'] if current_agent_row else None
current_agent_version = current_agent_row['version'] if current_agent_row else None
current_upgrade_status = current_agent_row['upgrade_status'] if current_agent_row else None
# Determine new status - preserve upgrading status unless version actually changed
new_status = current_agent_status or 'online'
+71
View File
@@ -0,0 +1,71 @@
"""v1.8.7: app version is single-source and cannot silently drift.
The UI shows the version via GET /api/version, which returns main.py's `_version_info`. That MUST be
sourced from the one canonical file backend/version.json (co-located with main.py so `COPY . .`
bakes it into every image, regardless of pipeline). main.py's in-code fallback must NOT be a real
version, otherwise it drifts when version.json is bumped but the constant is forgotten — exactly
what left the UI reporting 1.8.4 after 1.8.5/1.8.6 shipped. These checks fail loudly on regression.
"""
import ast
import json
import os
import re
import pytest
_BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # backend/
_VERSION_JSON = os.path.join(_BACKEND, "version.json")
_MAIN = os.path.join(_BACKEND, "main.py")
_SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
def test_canonical_version_file_exists_and_valid():
assert os.path.exists(_VERSION_JSON), "backend/version.json (single source of truth) is missing"
with open(_VERSION_JSON) as f:
data = json.load(f)
assert _SEMVER.match(data.get("version", "")), \
f"backend/version.json version is not semver: {data.get('version')!r}"
assert data.get("releaseName"), "backend/version.json must have a releaseName"
def _main_fallback_version_info():
"""The literal dict assigned to _version_info in main.py (the in-code fallback)."""
with open(_MAIN) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "_version_info":
return ast.literal_eval(node.value)
return None
def test_main_has_no_hardcoded_real_version():
fb = _main_fallback_version_info()
assert fb is not None, "could not find the _version_info fallback literal in main.py"
# Must be a neutral marker, never a real version that can drift out of sync.
assert not _SEMVER.match(str(fb.get("version", ""))), (
f"main.py hardcodes a real version {fb.get('version')!r}; it must be a neutral marker "
f"(e.g. 'unknown') so the version stays single-source in backend/version.json"
)
def test_main_loads_the_canonical_file_first():
# The first candidate path main.py reads must resolve to the co-located backend/version.json,
# so the correct version is available in every image (not dependent on CI staging).
with open(_MAIN) as f:
src = f.read()
assert 'os.path.dirname(__file__), "version.json"' in src, \
"main.py must read version.json co-located with the module (backend/version.json)"
def test_frontend_package_json_matches_when_present():
# Only meaningful in a full-repo checkout; the backend image build context (./backend) has no frontend/.
pkg = os.path.join(os.path.dirname(_BACKEND), "frontend", "package.json")
if not os.path.exists(pkg):
pytest.skip("frontend/package.json not in this context (e.g. backend-only image build)")
with open(_VERSION_JSON) as f:
canonical = json.load(f)["version"]
with open(pkg) as f:
fe = json.load(f)["version"]
assert fe == canonical, f"frontend/package.json {fe!r} != backend/version.json {canonical!r}"
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.8.7",
"releaseName": "Version reporting single-source fix",
"releaseDate": "2026-07-09"
}
-1
View File
@@ -10,7 +10,6 @@ services:
dockerfile: Dockerfile
volumes:
- haproxy_configs:/etc/haproxy
- ./version.json:/app/version.json:ro
frontend:
image: haproxy-openmanager-frontend:localtest
+3
View File
@@ -51,6 +51,9 @@ services:
- LOG_LEVEL=INFO
- PUBLIC_URL=http://localhost:8080
- MANAGEMENT_BASE_URL=http://localhost:8080
# Empty when unset on the host: the image CMD then falls back to
# WEB_CONCURRENCY (uvicorn's native env) and finally to 1.
- UVICORN_WORKERS=${UVICORN_WORKERS:-}
volumes:
- haproxy_configs:/etc/haproxy
expose:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.8.5",
"version": "1.8.7",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
-5
View File
@@ -1,5 +0,0 @@
{
"version": "1.8.5",
"releaseName": "ACME completion-task SQL fix",
"releaseDate": "2026-07-03"
}