diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ac2f308..a45ff54 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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 diff --git a/.gitignore b/.gitignore index f81b9b7..631943a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 8fd9c90..747b613 100644 --- a/README.md +++ b/README.md @@ -2082,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 @@ -2428,6 +2428,7 @@ 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-.sh` / `uninstall-agent-.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. diff --git a/backend/main.py b/backend/main.py index e916e6a..c31e9f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/tests/test_version_consistency.py b/backend/tests/test_version_consistency.py new file mode 100644 index 0000000..3403520 --- /dev/null +++ b/backend/tests/test_version_consistency.py @@ -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}" diff --git a/backend/version.json b/backend/version.json new file mode 100644 index 0000000..95e61d9 --- /dev/null +++ b/backend/version.json @@ -0,0 +1,5 @@ +{ + "version": "1.8.7", + "releaseName": "Version reporting single-source fix", + "releaseDate": "2026-07-09" +} diff --git a/docker-compose.localtest.yml b/docker-compose.localtest.yml index bce10d2..d55dccb 100644 --- a/docker-compose.localtest.yml +++ b/docker-compose.localtest.yml @@ -10,7 +10,6 @@ services: dockerfile: Dockerfile volumes: - haproxy_configs:/etc/haproxy - - ./version.json:/app/version.json:ro frontend: image: haproxy-openmanager-frontend:localtest diff --git a/frontend/package.json b/frontend/package.json index 349cd5e..be31c5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.8.6", + "version": "1.8.7", "description": "HAProxy Load Balancer Management UI", "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/version.json b/version.json deleted file mode 100644 index 5b833e0..0000000 --- a/version.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "1.8.6", - "releaseName": "Opt-in API workers + heartbeat micro-optimization", - "releaseDate": "2026-07-06" -}