mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-02 23:18:14 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f4fa71ed | |||
| 97b2452bd2 | |||
| 23257b02cf |
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.8.7",
|
||||
"releaseName": "Version reporting single-source fix",
|
||||
"releaseDate": "2026-07-09"
|
||||
}
|
||||
@@ -10,7 +10,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- haproxy_configs:/etc/haproxy
|
||||
- ./version.json:/app/version.json:ro
|
||||
|
||||
frontend:
|
||||
image: haproxy-openmanager-frontend:localtest
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": "1.8.5",
|
||||
"releaseName": "ACME completion-task SQL fix",
|
||||
"releaseDate": "2026-07-03"
|
||||
}
|
||||
Reference in New Issue
Block a user