mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-12 15:49:26 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c79391cd13 | |||
| 9e2ea04777 | |||
| 60f4fa71ed | |||
| 97b2452bd2 | |||
| 23257b02cf |
@@ -59,6 +59,15 @@ AGENT_HEARTBEAT_TIMEOUT_SECONDS=15
|
|||||||
# Config sync interval in seconds
|
# Config sync interval in seconds
|
||||||
AGENT_CONFIG_SYNC_INTERVAL_SECONDS=30
|
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
|
# CORS CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -21,27 +21,17 @@ jobs:
|
|||||||
- name: read product version
|
- name: read product version
|
||||||
id: prodversion
|
id: prodversion
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version version.json)
|
VERSION=$(jq -r .version backend/version.json)
|
||||||
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# The backend image is built with `context: ./backend`, so the
|
# version.json now lives at backend/version.json (inside the ./backend build
|
||||||
# repo-root version.json is OUTSIDE the build context and never
|
# context), so `COPY . .` bakes it into the image directly — no staging step
|
||||||
# reaches the container. Backend `main.py` falls back to a
|
# is needed and the backend reports the correct version in every deployment,
|
||||||
# compile-time constant when /app/version.json is missing, which
|
# not just this workflow's builds.
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: set up qemu
|
- name: set up qemu
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
@@ -91,7 +81,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.prodversion.outputs.VERSION }}"
|
VERSION="${{ steps.prodversion.outputs.VERSION }}"
|
||||||
TAG="v${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
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists, skipping."
|
echo "Release $TAG already exists, skipping."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ venv.bak/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.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
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.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
|
## API Reference
|
||||||
|
|
||||||
@@ -2069,7 +2082,7 @@ haproxy-openmanager/
|
|||||||
├── docker-compose.yml # Docker Compose configuration
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
├── docker-compose.localtest.yml # Local development/testing overrides
|
├── docker-compose.localtest.yml # Local development/testing overrides
|
||||||
├── docker-compose.test.yml # Test environment
|
├── 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
|
├── build-images.sh # Build Docker images
|
||||||
├── pytest.ini # Pytest configuration
|
├── pytest.ini # Pytest configuration
|
||||||
├── README.md # This file
|
├── README.md # This file
|
||||||
@@ -2415,6 +2428,8 @@ Developed with ❤️ for the HAProxy community
|
|||||||
|
|
||||||
## Release Notes
|
## 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.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.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.
|
- **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 port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run the application in production mode (without --reload)
|
# Run the application in production mode (without --reload).
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# 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}}"]
|
||||||
@@ -194,7 +194,14 @@ async def ensure_agents_table():
|
|||||||
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
|
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
|
||||||
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
|
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
|
||||||
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
|
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
|
||||||
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;"
|
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;",
|
||||||
|
# Issue #38: SPOE filter directives (e.g. Coraza WAF) and frontend
|
||||||
|
# log-format were silently dropped on bulk-import / manual edit
|
||||||
|
# because the parser recognised only a fixed set of directives.
|
||||||
|
# These nullable TEXT columns persist them verbatim (multi-line for
|
||||||
|
# `filters`), mirroring the request_headers/options passthrough.
|
||||||
|
'log_format': "ALTER TABLE frontends ADD COLUMN log_format TEXT;",
|
||||||
|
'filters': "ALTER TABLE frontends ADD COLUMN filters TEXT;"
|
||||||
}
|
}
|
||||||
|
|
||||||
for col, query in frontend_columns.items():
|
for col, query in frontend_columns.items():
|
||||||
@@ -1741,7 +1748,12 @@ async def ensure_agent_activity_logs_table():
|
|||||||
# columns on letsencrypt_accounts/letsencrypt_orders/acme_challenges and the brand-new
|
# columns on letsencrypt_accounts/letsencrypt_orders/acme_challenges and the brand-new
|
||||||
# letsencrypt_account_dns_credentials table (ensure_letsencrypt_dns_credentials step).
|
# letsencrypt_account_dns_credentials table (ensure_letsencrypt_dns_credentials step).
|
||||||
# All additive + idempotent; default challenge_type 'http-01' keeps existing flows byte-identical.
|
# All additive + idempotent; default challenge_type 'http-01' keeps existing flows byte-identical.
|
||||||
SCHEMA_VERSION = 8
|
# v1.8.8 (Issue #38 — SPOE filter + frontend log-format): bumped 8 -> 9 for the additive
|
||||||
|
# `log_format` + `filters` TEXT columns on `frontends` (frontend_columns loop). Without this
|
||||||
|
# bump, already-deployed databases (version >= 8) skip the whole migration run and never gain
|
||||||
|
# the columns, so the frontends SELECT/INSERT would fail. Additive + idempotent + nullable;
|
||||||
|
# existing rows stay NULL and render byte-identical.
|
||||||
|
SCHEMA_VERSION = 9
|
||||||
|
|
||||||
|
|
||||||
async def run_all_migrations():
|
async def run_all_migrations():
|
||||||
|
|||||||
+7
-3
@@ -8,9 +8,13 @@ import redis
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
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.
|
# Single source of truth: backend/version.json, which sits next to this module and is baked into
|
||||||
_version_info = {"version": "1.8.4", "releaseName": "Agent installer self-kill fix", "releaseDate": "2026-06-27"}
|
# every image by `COPY . .` (build context ./backend) — no pipeline staging needed. The literal
|
||||||
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
|
# 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:
|
try:
|
||||||
with open(_vpath) as _vf:
|
with open(_vpath) as _vf:
|
||||||
_version_info = json.load(_vf)
|
_version_info = json.load(_vf)
|
||||||
|
|||||||
+28
-45
@@ -85,6 +85,11 @@ class FrontendConfig(BaseModel):
|
|||||||
response_headers: Optional[str] = None
|
response_headers: Optional[str] = None
|
||||||
options: Optional[str] = None
|
options: Optional[str] = None
|
||||||
tcp_request_rules: Optional[str] = None
|
tcp_request_rules: Optional[str] = None
|
||||||
|
# Issue #38: SPOE filter directives (Coraza WAF etc.) + frontend log-format.
|
||||||
|
# Passthrough TEXT (no validator) — SPOE `filter ... config <path>` legitimately
|
||||||
|
# references an operator-managed file, so the ACL `-f` guard must NOT apply here.
|
||||||
|
log_format: Optional[str] = None
|
||||||
|
filters: Optional[str] = None
|
||||||
timeout_client: Optional[int] = None
|
timeout_client: Optional[int] = None
|
||||||
timeout_http_request: Optional[int] = None
|
timeout_http_request: Optional[int] = None
|
||||||
rate_limit: Optional[int] = None
|
rate_limit: Optional[int] = None
|
||||||
@@ -451,26 +456,17 @@ class FrontendConfig(BaseModel):
|
|||||||
if any(dangerous in rule.lower() for dangerous in ['$(', '`']):
|
if any(dangerous in rule.lower() for dangerous in ['$(', '`']):
|
||||||
raise ValueError(f'ACL rule contains potentially dangerous content: "{rule}"')
|
raise ValueError(f'ACL rule contains potentially dangerous content: "{rule}"')
|
||||||
|
|
||||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject
|
# Issue #38 follow-up — the `-f <file>` pattern-file flag
|
||||||
# the HAProxy `-f <file>` pattern-file flag here too so the
|
# is ACCEPTED here (the Bulgu #12 hard reject was removed).
|
||||||
# manual Frontend API mirrors the wizard's parity rule.
|
# Pattern files are operator-managed host files, exactly
|
||||||
# HAProxy OpenManager does not provision pattern files
|
# like the SPOE `filter ... config <path>` reference this
|
||||||
# onto the HAProxy node filesystem, so any `-f /path/...`
|
# release started preserving: bulk import always accepted
|
||||||
# reference will fail HAProxy's `-c` parse at apply time
|
# `-f`, the free-form fields (request_headers,
|
||||||
# with "failed to open pattern file". Reject up-front so
|
# tcp_request_rules) always accepted it, and the agent
|
||||||
# operators get the same actionable error from both the
|
# runs `haproxy -c` before every reload so a missing file
|
||||||
# manual page and the wizard.
|
# fails safely (previous config keeps running). The route
|
||||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
# handlers surface a non-blocking warning listing the
|
||||||
raise ValueError(
|
# referenced pattern files instead.
|
||||||
f'ACL rule "{rule}" uses the HAProxy `-f <file>` '
|
|
||||||
"pattern-file flag, which is not supported in "
|
|
||||||
"HAProxy OpenManager: the product does not "
|
|
||||||
"provision pattern files onto the HAProxy node "
|
|
||||||
"filesystem, so the reference would fail at "
|
|
||||||
"reload time. Use inline values instead "
|
|
||||||
"(e.g. `src 10.0.0.0/24` rather than "
|
|
||||||
"`src -f /etc/haproxy/admins.lst`)."
|
|
||||||
)
|
|
||||||
|
|
||||||
validated_rules.append(rule)
|
validated_rules.append(rule)
|
||||||
|
|
||||||
@@ -510,20 +506,12 @@ class FrontendConfig(BaseModel):
|
|||||||
if not any(rule.startswith(redirect_type) for redirect_type in valid_redirects):
|
if not any(rule.startswith(redirect_type) for redirect_type in valid_redirects):
|
||||||
raise ValueError(f'Invalid redirect rule: "{rule}". Must start with: location, prefix, or scheme.')
|
raise ValueError(f'Invalid redirect rule: "{rule}". Must start with: location, prefix, or scheme.')
|
||||||
|
|
||||||
# Phase K Phase D follow-up (Bulgu #12 round 3) —
|
# Issue #38 follow-up — `-f <file>` pattern-file references
|
||||||
# mirror the wizard's `-f <file>` guard here. The
|
# are ACCEPTED (Bulgu #12 hard reject removed; see
|
||||||
# `X !X` contradiction check used to live alongside
|
# validate_acl_rules for the full rationale). The `X !X`
|
||||||
# this guard, but Bulgu #62 (round-22 audit) moved
|
# contradiction check lives in the route handler
|
||||||
# it into the route handler so updates can grandfather
|
# (`routers/frontend.py::_collect_routing_rule_contradictions`,
|
||||||
# legacy rules created before the contradiction guard
|
# Bulgu #62) and is unchanged.
|
||||||
# landed. See `routers/frontend.py::_collect_routing_rule_contradictions`.
|
|
||||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
|
||||||
raise ValueError(
|
|
||||||
f'Redirect rule "{rule}" uses the HAProxy `-f <file>` '
|
|
||||||
"pattern-file flag, which is not supported in "
|
|
||||||
"HAProxy OpenManager: the product does not provision "
|
|
||||||
"pattern files onto the HAProxy node filesystem."
|
|
||||||
)
|
|
||||||
|
|
||||||
validated_rules.append(rule)
|
validated_rules.append(rule)
|
||||||
|
|
||||||
@@ -531,9 +519,12 @@ class FrontendConfig(BaseModel):
|
|||||||
|
|
||||||
@validator('use_backend_rules')
|
@validator('use_backend_rules')
|
||||||
def validate_use_backend_rules_syntax(cls, v):
|
def validate_use_backend_rules_syntax(cls, v):
|
||||||
"""Phase K Phase D follow-up (Bulgu #12 round 3) — manual
|
"""Manual Frontend API guard for dangerous shell patterns.
|
||||||
Frontend API parity guard: reject `-f <file>` references
|
|
||||||
and dangerous shell patterns.
|
Issue #38 follow-up — the Bulgu #12 `-f <file>` hard reject
|
||||||
|
was removed (see validate_acl_rules for the rationale);
|
||||||
|
pattern-file references are operator-managed host files and
|
||||||
|
are surfaced as non-blocking warnings by the route handlers.
|
||||||
|
|
||||||
Bulgu #62 (round-22 audit) — the `X !X` contradiction check
|
Bulgu #62 (round-22 audit) — the `X !X` contradiction check
|
||||||
previously lived here but moved into the route handler so
|
previously lived here but moved into the route handler so
|
||||||
@@ -563,13 +554,5 @@ class FrontendConfig(BaseModel):
|
|||||||
f'use_backend rule contains potentially dangerous '
|
f'use_backend rule contains potentially dangerous '
|
||||||
f'content: "{rule}"'
|
f'content: "{rule}"'
|
||||||
)
|
)
|
||||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
|
||||||
raise ValueError(
|
|
||||||
f'use_backend rule "{rule}" uses the HAProxy '
|
|
||||||
"`-f <file>` pattern-file flag, which is not "
|
|
||||||
"supported in HAProxy OpenManager: the product "
|
|
||||||
"does not provision pattern files onto the HAProxy "
|
|
||||||
"node filesystem."
|
|
||||||
)
|
|
||||||
validated_rules.append(rule)
|
validated_rules.append(rule)
|
||||||
return validated_rules
|
return validated_rules
|
||||||
@@ -179,35 +179,17 @@ _MAX_RULE_STRING_LEN = 4096
|
|||||||
# attempts.
|
# attempts.
|
||||||
_DANGEROUS_RULE_PATTERNS = ("$(", "`")
|
_DANGEROUS_RULE_PATTERNS = ("$(", "`")
|
||||||
|
|
||||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — the HAProxy `-f
|
# Issue #38 follow-up — the HAProxy `-f <file>` ACL/condition flag
|
||||||
# <file>` ACL/condition flag instructs HAProxy to load match patterns
|
# loads match patterns from a file on the HAProxy host. The Bulgu #12
|
||||||
# from a server-side file at parse time. HAProxy OpenManager is a
|
# hard reject (`_ACL_FILE_FLAG_PATTERN`/`_ACL_FILE_FLAG_MESSAGE`) was
|
||||||
# fully-managed product: we do NOT provision pattern files onto the
|
# removed: pattern files are operator-managed host files (exactly like
|
||||||
# HAProxy node's filesystem, and operators have no UI to upload one.
|
# the SPOE `filter ... config <path>` reference preserved since
|
||||||
# A `-f /some/path` reference therefore ALWAYS resolves to
|
# v1.8.8), bulk import and the free-form fields (tcp_request_rules,
|
||||||
# "file not found" when HAProxy's real `-c` parse runs at apply
|
# request_headers) always accepted them, and the agent runs
|
||||||
# time, producing exactly the operator-reported failure mode:
|
# `haproxy -c` before every reload so a missing file fails safely
|
||||||
# [ALERT] parsing ACL 'acl1' : failed to open pattern file </path>.
|
# (the previous config keeps running). The manual frontend route
|
||||||
# [ALERT] parsing switching rule : no such ACL : 'acl1'.
|
# handlers emit a non-blocking warning listing referenced pattern
|
||||||
#
|
# files (`routers/frontend.py::_pattern_file_warnings`).
|
||||||
# Surface this BEFORE persist by rejecting `-f` in any rule string
|
|
||||||
# that comes through the wizard / manual frontend API. Reject ALL
|
|
||||||
# variants (` -f `, leading `-f `, trailing `... -f`) defensively so
|
|
||||||
# operators cannot slip the flag through with creative spacing.
|
|
||||||
# The check is anchored to ACL/condition rule strings only; raw
|
|
||||||
# HAProxy snippet fields (tcp_request_rules, request_headers, ...)
|
|
||||||
# are NOT touched because those are inherently free-form and
|
|
||||||
# advanced operators may legitimately reference pre-provisioned
|
|
||||||
# pattern files there.
|
|
||||||
_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)")
|
|
||||||
_ACL_FILE_FLAG_MESSAGE = (
|
|
||||||
"pattern-file references with '-f <file>' are not supported in ACL / "
|
|
||||||
"use_backend / redirect rules: HAProxy OpenManager does not provision "
|
|
||||||
"pattern files onto the HAProxy node's filesystem, so the reference "
|
|
||||||
"would always fail at HAProxy reload time. Use inline values "
|
|
||||||
"instead (e.g. `acl is_admin src 10.0.0.0/24` rather than "
|
|
||||||
"`acl is_admin src -f /etc/haproxy/admins.lst`)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Phase K Phase D follow-up (Bulgu #13) — detect a routing /
|
# Phase K Phase D follow-up (Bulgu #13) — detect a routing /
|
||||||
# redirect rule whose condition references the SAME ACL in both
|
# redirect rule whose condition references the SAME ACL in both
|
||||||
@@ -305,13 +287,10 @@ def _validate_haproxy_directive_string(
|
|||||||
f"{field_label} entry contains potentially dangerous content: "
|
f"{field_label} entry contains potentially dangerous content: "
|
||||||
f"{pattern!r}"
|
f"{pattern!r}"
|
||||||
)
|
)
|
||||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject the
|
# Issue #38 follow-up — `-f <file>` pattern-file references are
|
||||||
# HAProxy `-f <file>` pattern-file flag because OpenManager does
|
# ACCEPTED (Bulgu #12 hard reject removed; see the module-level
|
||||||
# not manage the HAProxy node filesystem. See the module-level
|
# `_ACL_FILE_FLAG_PATTERN` comment). The route handlers surface
|
||||||
# `_ACL_FILE_FLAG_PATTERN` docstring for the full operator-
|
# a non-blocking pattern-file warning instead.
|
||||||
# reported failure mode this guards against.
|
|
||||||
if _ACL_FILE_FLAG_PATTERN.search(stripped):
|
|
||||||
raise ValueError(f"{field_label}: {_ACL_FILE_FLAG_MESSAGE}")
|
|
||||||
# Phase K Phase D follow-up (Bulgu #13) — for routing /
|
# Phase K Phase D follow-up (Bulgu #13) — for routing /
|
||||||
# redirect rules (not ACL definitions themselves), reject a
|
# redirect rules (not ACL definitions themselves), reject a
|
||||||
# condition that contains the same ACL in both positive and
|
# condition that contains the same ACL in both positive and
|
||||||
@@ -1083,21 +1062,12 @@ class FrontendStep(BaseModel):
|
|||||||
normalised: List[Union[str, dict]] = []
|
normalised: List[Union[str, dict]] = []
|
||||||
for el in v:
|
for el in v:
|
||||||
if isinstance(el, dict):
|
if isinstance(el, dict):
|
||||||
# Phase K Phase D follow-up (Bulgu #12 round 3
|
# Issue #38 follow-up — dict-shaped redirect rules may
|
||||||
# extension) — dict-shaped redirect rules emit their
|
# carry `-f <file>` pattern-file references in their
|
||||||
# `condition` / `target` fields VERBATIM into the
|
# `condition`/`target` values; these are ACCEPTED now
|
||||||
# rendered HAProxy directive. A dict with
|
# (Bulgu #12 hard reject removed — operator-managed
|
||||||
# `condition: "if { src -f /etc/haproxy/x.lst }"`
|
# host files, fail-safe apply; see module-level
|
||||||
# would slip past the string-only validator above
|
# `_ACL_FILE_FLAG_PATTERN` comment).
|
||||||
# and trigger the same operator-reported "failed to
|
|
||||||
# open pattern file" rejection at apply time. Reject
|
|
||||||
# `-f` in any string-shaped value the dict carries.
|
|
||||||
for field_name in ("condition", "target", "type"):
|
|
||||||
val = el.get(field_name)
|
|
||||||
if isinstance(val, str) and _ACL_FILE_FLAG_PATTERN.search(val):
|
|
||||||
raise ValueError(
|
|
||||||
f"redirect_rules.{field_name}: {_ACL_FILE_FLAG_MESSAGE}"
|
|
||||||
)
|
|
||||||
# Bulgu #13 extension — same contradiction guard
|
# Bulgu #13 extension — same contradiction guard
|
||||||
# for dict-shaped redirect conditions.
|
# for dict-shaped redirect conditions.
|
||||||
cond_val = el.get("condition")
|
cond_val = el.get("condition")
|
||||||
|
|||||||
@@ -1695,9 +1695,13 @@ async def agent_heartbeat_by_name(
|
|||||||
""", x_api_key)
|
""", x_api_key)
|
||||||
|
|
||||||
# Check if agent was in upgrading status and version has changed
|
# 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)
|
# (v1.8.6: one round-trip instead of three; row is None exactly when the
|
||||||
current_agent_version = await conn.fetchval("SELECT version FROM agents WHERE id = $1", agent_id)
|
# per-column fetchvals would each have returned None)
|
||||||
current_upgrade_status = await conn.fetchval("SELECT upgrade_status FROM agents WHERE id = $1", agent_id)
|
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
|
# Determine new status - preserve upgrading status unless version actually changed
|
||||||
new_status = current_agent_status or 'online'
|
new_status = current_agent_status or 'online'
|
||||||
|
|||||||
@@ -3700,13 +3700,15 @@ async def confirm_restore_config_version(
|
|||||||
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
||||||
mode = $4, ssl_enabled = $5, ssl_port = $6,
|
mode = $4, ssl_enabled = $5, ssl_port = $6,
|
||||||
maxconn = $7, timeout_client = $8,
|
maxconn = $7, timeout_client = $8,
|
||||||
|
log_format = $11, filters = $12,
|
||||||
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
|
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
|
||||||
WHERE id = $9 AND cluster_id = $10
|
WHERE id = $9 AND cluster_id = $10
|
||||||
""",
|
""",
|
||||||
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
|
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
|
||||||
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||||
fe_id, cluster_id
|
fe_id, cluster_id,
|
||||||
|
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||||
)
|
)
|
||||||
changes_summary["frontends_updated"] += 1
|
changes_summary["frontends_updated"] += 1
|
||||||
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||||
@@ -3717,13 +3719,14 @@ async def confirm_restore_config_version(
|
|||||||
INSERT INTO frontends
|
INSERT INTO frontends
|
||||||
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
|
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
|
||||||
maxconn, timeout_client,
|
maxconn, timeout_client,
|
||||||
cluster_id, is_active, last_config_status, created_at, updated_at)
|
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
""",
|
""",
|
||||||
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
|
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
|
||||||
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||||
cluster_id
|
cluster_id,
|
||||||
|
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||||
)
|
)
|
||||||
changes_summary["frontends_created"] += 1
|
changes_summary["frontends_created"] += 1
|
||||||
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||||
|
|||||||
@@ -855,6 +855,9 @@ async def parse_bulk_config(
|
|||||||
"response_headers": frontend.response_headers,
|
"response_headers": frontend.response_headers,
|
||||||
"options": frontend.options,
|
"options": frontend.options,
|
||||||
"tcp_request_rules": frontend.tcp_request_rules,
|
"tcp_request_rules": frontend.tcp_request_rules,
|
||||||
|
# Issue #38: SPOE filters + frontend log-format
|
||||||
|
"log_format": frontend.log_format,
|
||||||
|
"filters": frontend.filters,
|
||||||
# CRITICAL: SSL Advanced Options (parsed from bind directive)
|
# CRITICAL: SSL Advanced Options (parsed from bind directive)
|
||||||
"ssl_alpn": frontend.ssl_alpn,
|
"ssl_alpn": frontend.ssl_alpn,
|
||||||
"ssl_npn": frontend.ssl_npn,
|
"ssl_npn": frontend.ssl_npn,
|
||||||
@@ -1090,6 +1093,68 @@ async def parse_bulk_config(
|
|||||||
# Add auto-assignment info at the beginning
|
# Add auto-assignment info at the beginning
|
||||||
enhanced_warnings = ssl_auto_assign_info + enhanced_warnings
|
enhanced_warnings = ssl_auto_assign_info + enhanced_warnings
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Issue #38: SPOE pre-flight advisories. Surface, at preview time, the
|
||||||
|
# SPOE configurations that would FAIL HAProxy's `haproxy -c` at apply so
|
||||||
|
# the operator sees them BEFORE importing. Cluster-aware: the referenced
|
||||||
|
# SPOE engine config (e.g. coraza.cfg) is a sibling of the cluster's
|
||||||
|
# haproxy_config_path, which HAProxy OpenManager does not provision.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
_cfg_path = await conn.fetchval(
|
||||||
|
"SELECT haproxy_config_path FROM haproxy_clusters WHERE id = $1",
|
||||||
|
request.cluster_id,
|
||||||
|
) or "/etc/haproxy/haproxy.cfg"
|
||||||
|
_cfg_dir = _cfg_path.rsplit("/", 1)[0] or "/etc/haproxy"
|
||||||
|
for _fe in frontends_data:
|
||||||
|
_rh = _fe.get("request_headers") or ""
|
||||||
|
_filters = _fe.get("filters") or ""
|
||||||
|
# engines declared by `filter spoe engine <name> config <path>`
|
||||||
|
_declared_engines = set(re.findall(
|
||||||
|
r"filter\s+spoe\s+engine\s+(\S+)", _filters, re.IGNORECASE))
|
||||||
|
# engines referenced by `... send-spoe-group <name> <group>`
|
||||||
|
_used_engines = set(re.findall(
|
||||||
|
r"send-spoe-group\s+(\S+)", _rh, re.IGNORECASE))
|
||||||
|
_missing = _used_engines - _declared_engines
|
||||||
|
if _missing:
|
||||||
|
enhanced_warnings.append(
|
||||||
|
f"⚠️ Frontend '{_fe['name']}': 'send-spoe-group' references SPOE "
|
||||||
|
f"engine(s) {', '.join(sorted(_missing))} but no matching "
|
||||||
|
f"'filter spoe engine <name> ...' line was found. HAProxy will "
|
||||||
|
f"reject this at apply with \"unable to find SPOE engine\". Add the "
|
||||||
|
f"filter line to this frontend."
|
||||||
|
)
|
||||||
|
for _path in re.findall(
|
||||||
|
r"filter\s+spoe\s+engine\s+\S+\s+config\s+(\S+)",
|
||||||
|
_filters, re.IGNORECASE):
|
||||||
|
enhanced_warnings.append(
|
||||||
|
f"ℹ️ Frontend '{_fe['name']}': SPOE engine config '{_path}' and its "
|
||||||
|
f"SPOA backend must exist on the HAProxy host (cluster config dir: "
|
||||||
|
f"{_cfg_dir}). HAProxy OpenManager preserves the filter directive but "
|
||||||
|
f"does not provision these files; otherwise 'haproxy -c' fails at apply."
|
||||||
|
)
|
||||||
|
# Issue #38 follow-up: ACL `-f <file>` pattern-file advisory.
|
||||||
|
# Scan only the structured rule fields (acl/use_backend) —
|
||||||
|
# request_headers/tcp_request_rules were always free-form and
|
||||||
|
# warning on them now would add new noise for existing users.
|
||||||
|
_pattern_paths = []
|
||||||
|
for _rule in (_fe.get("acl_rules") or []) + (_fe.get("use_backend_rules") or []):
|
||||||
|
if isinstance(_rule, str):
|
||||||
|
_pattern_paths.extend(
|
||||||
|
re.findall(r"(?:^|\s)-f\s+(\S+)", _rule))
|
||||||
|
if _pattern_paths:
|
||||||
|
_uniq = sorted(set(_pattern_paths))
|
||||||
|
enhanced_warnings.append(
|
||||||
|
f"ℹ️ Frontend '{_fe['name']}': ACL/routing rules reference pattern "
|
||||||
|
f"file(s) {', '.join(_uniq)}. Each file must exist at that exact path "
|
||||||
|
f"on every HAProxy host in the cluster (cluster config dir: {_cfg_dir}) "
|
||||||
|
f"— HAProxy OpenManager does not create or distribute pattern files. "
|
||||||
|
f"A missing file fails safely at 'haproxy -c' (previous config keeps "
|
||||||
|
f"running)."
|
||||||
|
)
|
||||||
|
except Exception as _spoe_adv_err:
|
||||||
|
logger.warning(f"SPOE advisory generation skipped: {_spoe_adv_err}")
|
||||||
|
|
||||||
# BULK IMPORT MVP: Check existing entities for UPSERT detection
|
# BULK IMPORT MVP: Check existing entities for UPSERT detection
|
||||||
# Mark each entity as new or update for UI display
|
# Mark each entity as new or update for UI display
|
||||||
# CRITICAL: Only mark as UPDATE if there are actual field changes
|
# CRITICAL: Only mark as UPDATE if there are actual field changes
|
||||||
@@ -1150,6 +1215,16 @@ async def parse_bulk_config(
|
|||||||
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
|
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
|
||||||
has_changes = True
|
has_changes = True
|
||||||
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
|
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
|
||||||
|
# Issue #38: SPOE filters + log-format change detection. REQUIRED for
|
||||||
|
# persistence (not just display): without it, an import that only adds
|
||||||
|
# a `filter`/`log-format` to an existing frontend would be flagged
|
||||||
|
# "no change" and the directive would never be written to the DB.
|
||||||
|
if frontend.get("log_format") and frontend["log_format"] != existing.get("log_format"):
|
||||||
|
has_changes = True
|
||||||
|
changes["log_format"] = {"old": existing.get("log_format"), "new": frontend["log_format"]}
|
||||||
|
if frontend.get("filters") and frontend["filters"] != existing.get("filters"):
|
||||||
|
has_changes = True
|
||||||
|
changes["filters"] = {"old": existing.get("filters"), "new": frontend["filters"]}
|
||||||
|
|
||||||
# CRITICAL: SSL Advanced Options change detection
|
# CRITICAL: SSL Advanced Options change detection
|
||||||
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
|
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
|
||||||
@@ -2095,6 +2170,17 @@ async def bulk_create_entities(
|
|||||||
update_values.append(frontend_data["options"])
|
update_values.append(frontend_data["options"])
|
||||||
param_index += 1
|
param_index += 1
|
||||||
|
|
||||||
|
# Issue #38: SPOE filters + frontend log-format (merge strategy)
|
||||||
|
if frontend_data.get("log_format") and frontend_data["log_format"] != existing_full.get("log_format"):
|
||||||
|
update_fields.append(f"log_format = ${param_index}")
|
||||||
|
update_values.append(frontend_data["log_format"])
|
||||||
|
param_index += 1
|
||||||
|
|
||||||
|
if frontend_data.get("filters") and frontend_data["filters"] != existing_full.get("filters"):
|
||||||
|
update_fields.append(f"filters = ${param_index}")
|
||||||
|
update_values.append(frontend_data["filters"])
|
||||||
|
param_index += 1
|
||||||
|
|
||||||
# CRITICAL FIX: Update SSL advanced options (alpn, npn, ciphers, etc.)
|
# CRITICAL FIX: Update SSL advanced options (alpn, npn, ciphers, etc.)
|
||||||
# These are parsed from bind directive and should be preserved in database
|
# These are parsed from bind directive and should be preserved in database
|
||||||
if "ssl_alpn" in frontend_data and frontend_data.get("ssl_alpn") != existing_full.get("ssl_alpn"):
|
if "ssl_alpn" in frontend_data and frontend_data.get("ssl_alpn") != existing_full.get("ssl_alpn"):
|
||||||
@@ -2214,9 +2300,10 @@ async def bulk_create_entities(
|
|||||||
timeout_client, timeout_http_request, maxconn,
|
timeout_client, timeout_http_request, maxconn,
|
||||||
request_headers, response_headers, tcp_request_rules, options,
|
request_headers, response_headers, tcp_request_rules, options,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
cluster_id, acl_rules, use_backend_rules, redirect_rules, updated_at
|
cluster_id, acl_rules, use_backend_rules, redirect_rules,
|
||||||
|
log_format, filters, updated_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
frontend_data["name"],
|
frontend_data["name"],
|
||||||
@@ -2257,7 +2344,9 @@ async def bulk_create_entities(
|
|||||||
request.cluster_id,
|
request.cluster_id,
|
||||||
json.dumps(frontend_data.get("acl_rules", [])), # acl_rules
|
json.dumps(frontend_data.get("acl_rules", [])), # acl_rules
|
||||||
json.dumps(frontend_data.get("use_backend_rules", [])), # use_backend_rules
|
json.dumps(frontend_data.get("use_backend_rules", [])), # use_backend_rules
|
||||||
json.dumps([]) # redirect_rules
|
json.dumps([]), # redirect_rules
|
||||||
|
frontend_data.get("log_format"), # Issue #38
|
||||||
|
frontend_data.get("filters") # Issue #38
|
||||||
)
|
)
|
||||||
|
|
||||||
created_entities["frontends"].append({
|
created_entities["frontends"].append({
|
||||||
|
|||||||
@@ -117,6 +117,41 @@ def _rule_contradiction_text(rule: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_file_warnings(
|
||||||
|
acl_rules: Optional[List[Any]] = None,
|
||||||
|
use_backend_rules: Optional[List[Any]] = None,
|
||||||
|
redirect_rules: Optional[List[Any]] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Issue #38 follow-up — non-blocking `-f <file>` pattern-file
|
||||||
|
advisory for the manual frontend API.
|
||||||
|
|
||||||
|
The Bulgu #12 hard reject was removed from the Pydantic models:
|
||||||
|
pattern files are operator-managed host files (same policy as the
|
||||||
|
SPOE `filter ... config <path>` reference preserved since v1.8.8)
|
||||||
|
and the agent's pre-reload `haproxy -c` makes a missing file fail
|
||||||
|
safely. This helper returns one warning listing the unique file
|
||||||
|
paths referenced across the rule fields, or [] when no rule uses
|
||||||
|
`-f` — operators who don't use pattern files see no change.
|
||||||
|
"""
|
||||||
|
paths: List[str] = []
|
||||||
|
for rules in (acl_rules, use_backend_rules, redirect_rules):
|
||||||
|
for rule in rules or []:
|
||||||
|
text = rule if isinstance(rule, str) else (
|
||||||
|
rule.get("condition") if isinstance(rule, dict) else None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
paths.extend(re.findall(r"(?:^|\s)-f\s+(\S+)", text))
|
||||||
|
if not paths:
|
||||||
|
return []
|
||||||
|
uniq = sorted(set(paths))
|
||||||
|
return [
|
||||||
|
f"ACL/routing rules reference pattern file(s) {', '.join(uniq)}. "
|
||||||
|
f"Each file must exist at that exact path on every HAProxy host "
|
||||||
|
f"in the cluster — HAProxy OpenManager does not create or "
|
||||||
|
f"distribute pattern files. A missing file fails safely at "
|
||||||
|
f"'haproxy -c' (the previous config keeps running)."
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _collect_routing_rule_contradictions(
|
def _collect_routing_rule_contradictions(
|
||||||
rules: List[Any], origin_label: str,
|
rules: List[Any], origin_label: str,
|
||||||
) -> List[Tuple[str, Any]]:
|
) -> List[Tuple[str, Any]]:
|
||||||
@@ -408,6 +443,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -424,6 +460,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -453,6 +490,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -465,6 +503,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -480,6 +519,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -492,6 +532,7 @@ async def get_frontends(
|
|||||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules,
|
request_headers, response_headers, options, tcp_request_rules,
|
||||||
|
log_format, filters,
|
||||||
timeout_client, timeout_http_request,
|
timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||||
@@ -601,6 +642,8 @@ async def get_frontends(
|
|||||||
"response_headers": f.get("response_headers"),
|
"response_headers": f.get("response_headers"),
|
||||||
"options": f.get("options"),
|
"options": f.get("options"),
|
||||||
"tcp_request_rules": f.get("tcp_request_rules"),
|
"tcp_request_rules": f.get("tcp_request_rules"),
|
||||||
|
"log_format": f.get("log_format"), # Issue #38
|
||||||
|
"filters": f.get("filters"), # Issue #38
|
||||||
"timeout_client": f.get("timeout_client"),
|
"timeout_client": f.get("timeout_client"),
|
||||||
"timeout_http_request": f.get("timeout_http_request"),
|
"timeout_http_request": f.get("timeout_http_request"),
|
||||||
"rate_limit": f.get("rate_limit"),
|
"rate_limit": f.get("rate_limit"),
|
||||||
@@ -735,8 +778,8 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
|||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
cluster_id, maxconn, updated_at
|
cluster_id, maxconn, log_format, filters, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||||
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
|
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
|
||||||
@@ -746,7 +789,7 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
|||||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||||
frontend.cluster_id, frontend.maxconn)
|
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters)
|
||||||
|
|
||||||
# If cluster_id provided, create new config version for agents
|
# If cluster_id provided, create new config version for agents
|
||||||
sync_results = []
|
sync_results = []
|
||||||
@@ -825,12 +868,19 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
|||||||
user_agent=request.headers.get('user-agent')
|
user_agent=request.headers.get('user-agent')
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
response: dict = {
|
||||||
"message": f"Frontend '{frontend.name}' created successfully",
|
"message": f"Frontend '{frontend.name}' created successfully",
|
||||||
"id": frontend_id,
|
"id": frontend_id,
|
||||||
"frontend": frontend.dict(),
|
"frontend": frontend.dict(),
|
||||||
"sync_results": sync_results
|
"sync_results": sync_results
|
||||||
}
|
}
|
||||||
|
# Issue #38 follow-up — non-blocking pattern-file advisory
|
||||||
|
# (additive field; absent when no rule references `-f`).
|
||||||
|
pattern_warnings = _pattern_file_warnings(
|
||||||
|
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
|
||||||
|
if pattern_warnings:
|
||||||
|
response["warnings"] = pattern_warnings
|
||||||
|
return response
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1060,8 +1110,9 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
|||||||
acl_rules = $20, redirect_rules = $21, use_backend_rules = $22,
|
acl_rules = $20, redirect_rules = $21, use_backend_rules = $22,
|
||||||
request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
||||||
rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32,
|
rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32,
|
||||||
cluster_id = $33, maxconn = $34, updated_at = CURRENT_TIMESTAMP
|
cluster_id = $33, maxconn = $34, log_format = $35, filters = $36,
|
||||||
WHERE id = $35
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $37
|
||||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||||
frontend.default_backend, frontend.mode, ssl_enabled,
|
frontend.default_backend, frontend.mode, ssl_enabled,
|
||||||
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
|
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
|
||||||
@@ -1070,7 +1121,7 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
|||||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||||
frontend.cluster_id, frontend.maxconn, frontend_id)
|
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters, frontend_id)
|
||||||
|
|
||||||
# Debug: Check what was actually saved
|
# Debug: Check what was actually saved
|
||||||
updated_frontend = await conn.fetchrow("""
|
updated_frontend = await conn.fetchrow("""
|
||||||
@@ -1124,6 +1175,8 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
|||||||
"response_headers": frontend.response_headers,
|
"response_headers": frontend.response_headers,
|
||||||
"options": filtered_options,
|
"options": filtered_options,
|
||||||
"tcp_request_rules": frontend.tcp_request_rules,
|
"tcp_request_rules": frontend.tcp_request_rules,
|
||||||
|
"log_format": frontend.log_format, # Issue #38
|
||||||
|
"filters": frontend.filters, # Issue #38
|
||||||
"timeout_client": frontend.timeout_client,
|
"timeout_client": frontend.timeout_client,
|
||||||
"timeout_http_request": frontend.timeout_http_request,
|
"timeout_http_request": frontend.timeout_http_request,
|
||||||
"rate_limit": frontend.rate_limit,
|
"rate_limit": frontend.rate_limit,
|
||||||
@@ -1235,8 +1288,12 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
|||||||
# yellow toast on the next refresh. The save SUCCEEDED; the
|
# yellow toast on the next refresh. The save SUCCEEDED; the
|
||||||
# warnings only flag latent legacy data the operator may
|
# warnings only flag latent legacy data the operator may
|
||||||
# want to clean up at their convenience.
|
# want to clean up at their convenience.
|
||||||
if contradiction_warnings:
|
# Issue #38 follow-up — append the pattern-file advisory to
|
||||||
response["warnings"] = contradiction_warnings
|
# the same list (additive; empty when no rule uses `-f`).
|
||||||
|
all_warnings = list(contradiction_warnings or []) + _pattern_file_warnings(
|
||||||
|
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
|
||||||
|
if all_warnings:
|
||||||
|
response["warnings"] = all_warnings
|
||||||
return response
|
return response
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ async def create_frontend_row(
|
|||||||
acl_rules, redirect_rules, use_backend_rules,
|
acl_rules, redirect_rules, use_backend_rules,
|
||||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||||
rate_limit, compression, log_separate, monitor_uri,
|
rate_limit, compression, log_separate, monitor_uri,
|
||||||
cluster_id, maxconn, updated_at
|
cluster_id, maxconn, log_format, filters, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||||
$13, $14, $15, $16, $17, $18, $19,
|
$13, $14, $15, $16, $17, $18, $19,
|
||||||
$20, $21, $22,
|
$20, $21, $22,
|
||||||
$23, $24, $25, $26, $27, $28,
|
$23, $24, $25, $26, $27, $28,
|
||||||
$29, $30, $31, $32,
|
$29, $30, $31, $32,
|
||||||
$33, $34, CURRENT_TIMESTAMP
|
$33, $34, $35, $36, CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
@@ -101,6 +101,8 @@ async def create_frontend_row(
|
|||||||
getattr(payload, "monitor_uri", None),
|
getattr(payload, "monitor_uri", None),
|
||||||
cluster_id,
|
cluster_id,
|
||||||
getattr(payload, "maxconn", None),
|
getattr(payload, "maxconn", None),
|
||||||
|
getattr(payload, "log_format", None), # Issue #38
|
||||||
|
getattr(payload, "filters", None), # Issue #38
|
||||||
)
|
)
|
||||||
|
|
||||||
if mark_pending:
|
if mark_pending:
|
||||||
|
|||||||
@@ -393,6 +393,12 @@ def _categorize_haproxy_directive(line: str) -> str:
|
|||||||
return "prelude"
|
return "prelude"
|
||||||
if s.startswith("acl "):
|
if s.startswith("acl "):
|
||||||
return "acl"
|
return "acl"
|
||||||
|
# Issue #38: SPOE (and other) `filter` directives must be declared BEFORE
|
||||||
|
# the `http-request send-spoe-group` rules that use them, otherwise HAProxy
|
||||||
|
# fails with "unable to find SPOE engine". Own bucket, flushed right after
|
||||||
|
# `prelude` and before tcp_req/acl/http_req (see flush order below).
|
||||||
|
if s.startswith("filter "):
|
||||||
|
return "filter"
|
||||||
if s.startswith("stick-table") or s.startswith("stick "):
|
if s.startswith("stick-table") or s.startswith("stick "):
|
||||||
return "stick"
|
return "stick"
|
||||||
if s.startswith("tcp-request"):
|
if s.startswith("tcp-request"):
|
||||||
@@ -414,6 +420,7 @@ def _categorize_haproxy_directive(line: str) -> str:
|
|||||||
or s.startswith("compression ")
|
or s.startswith("compression ")
|
||||||
or s.startswith("monitor-uri")
|
or s.startswith("monitor-uri")
|
||||||
or s.startswith("log ")
|
or s.startswith("log ")
|
||||||
|
or s.startswith("log-format") # Issue #38: log-format / log-format-sd
|
||||||
or s.startswith("description ")
|
or s.startswith("description ")
|
||||||
or s.startswith("disabled")
|
or s.startswith("disabled")
|
||||||
or s.startswith("enabled")
|
or s.startswith("enabled")
|
||||||
@@ -903,7 +910,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
|||||||
# "stick-table already declared").
|
# "stick-table already declared").
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
_fe_buckets: Dict[str, List[str]] = {
|
_fe_buckets: Dict[str, List[str]] = {
|
||||||
"prelude": [], "stick": [], "tcp_req": [],
|
"prelude": [], "filter": [], "stick": [], "tcp_req": [],
|
||||||
"acl": [], "http_req": [], "http_resp": [],
|
"acl": [], "http_req": [], "http_resp": [],
|
||||||
"redirect": [], "use_be": [], "default_be": [],
|
"redirect": [], "use_be": [], "default_be": [],
|
||||||
}
|
}
|
||||||
@@ -996,6 +1003,17 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
|||||||
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||||
_emit_fe(f" {line_stripped}")
|
_emit_fe(f" {line_stripped}")
|
||||||
|
|
||||||
|
# Issue #38: emit frontend log-format and SPOE (etc.) filter directives.
|
||||||
|
# `log_format` routes to the `prelude` bucket, `filters` to the `filter`
|
||||||
|
# bucket (both via _emit_fe → _categorize_haproxy_directive), guaranteeing
|
||||||
|
# `filter ...` is rendered before the `http-request send-spoe-group` rules.
|
||||||
|
for _fld in ('log_format', 'filters'):
|
||||||
|
if frontend.get(_fld):
|
||||||
|
for line in frontend[_fld].split('\n'):
|
||||||
|
line_stripped = line.strip()
|
||||||
|
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||||
|
_emit_fe(f" {line_stripped}")
|
||||||
|
|
||||||
# CRITICAL: Validate frontend-backend mode compatibility
|
# CRITICAL: Validate frontend-backend mode compatibility
|
||||||
if frontend.get('default_backend'):
|
if frontend.get('default_backend'):
|
||||||
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
|
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
|
||||||
@@ -1223,6 +1241,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
for _bucket_key in (
|
for _bucket_key in (
|
||||||
"prelude",
|
"prelude",
|
||||||
|
"filter",
|
||||||
"stick",
|
"stick",
|
||||||
"tcp_req",
|
"tcp_req",
|
||||||
"acl",
|
"acl",
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Issue #38 follow-up — ACL `-f <file>` pattern-file support (v1.8.9).
|
||||||
|
|
||||||
|
The Bulgu #12 hard rejects were removed: pattern files are
|
||||||
|
operator-managed host files (same policy as the SPOE
|
||||||
|
`filter ... config <path>` reference preserved since v1.8.8), bulk
|
||||||
|
import always accepted `-f`, and the agent runs `haproxy -c` before
|
||||||
|
every reload so a missing file fails safely. These tests pin:
|
||||||
|
|
||||||
|
1. ACCEPT — the manual FrontendConfig model and the wizard models
|
||||||
|
accept `-f` in every rule field (string + dict shapes).
|
||||||
|
2. GUARDS KEPT — `$(`/backtick shell-substitution rejects and the
|
||||||
|
`X !X` contradiction machinery are unchanged.
|
||||||
|
3. WARNINGS — `_pattern_file_warnings` emits exactly one advisory
|
||||||
|
listing the referenced files, and NOTHING for `-f`-free rules
|
||||||
|
(zero-noise: existing users see no new output).
|
||||||
|
4. ADVISORY — the bulk-import preview advisory block scans
|
||||||
|
acl/use_backend rules (and only those fields).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from models.frontend import FrontendConfig # noqa: E402
|
||||||
|
from routers.frontend import _pattern_file_warnings # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
ACL_F = "blacklisted src -f /etc/haproxy/blacklist.lst"
|
||||||
|
UB_F = "be-secure if { src -f /etc/haproxy/allowlist.lst }"
|
||||||
|
REDIR_F = "location /blocked if { src -f /etc/haproxy/blacklist.lst }"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. ACCEPT — manual FrontendConfig model
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_config_accepts_acl_file_flag():
|
||||||
|
fe = FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[ACL_F])
|
||||||
|
assert fe.acl_rules == [ACL_F]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_config_accepts_use_backend_file_flag():
|
||||||
|
fe = FrontendConfig(
|
||||||
|
name="fe1", bind_port=80, mode="http", use_backend_rules=[UB_F])
|
||||||
|
assert fe.use_backend_rules == [UB_F]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_config_accepts_redirect_string_file_flag():
|
||||||
|
fe = FrontendConfig(
|
||||||
|
name="fe1", bind_port=80, mode="http", redirect_rules=[REDIR_F])
|
||||||
|
assert fe.redirect_rules == [REDIR_F]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_config_accepts_redirect_dict_file_flag():
|
||||||
|
rule = {"type": "scheme", "scheme": "https",
|
||||||
|
"condition": "if { src -f /etc/haproxy/blacklist.lst }"}
|
||||||
|
fe = FrontendConfig(
|
||||||
|
name="fe1", bind_port=80, mode="http", redirect_rules=[rule])
|
||||||
|
assert fe.redirect_rules == [rule]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. GUARDS KEPT — dangerous-content rejects unchanged
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_rule", [
|
||||||
|
"acl1 path $(rm -rf /)",
|
||||||
|
"acl1 path `id`",
|
||||||
|
])
|
||||||
|
def test_acl_shell_substitution_still_rejected(bad_rule):
|
||||||
|
from pydantic import ValidationError
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[bad_rule])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_rule", [
|
||||||
|
"be1 if $(whoami)",
|
||||||
|
"be1 if `id`",
|
||||||
|
])
|
||||||
|
def test_use_backend_shell_substitution_still_rejected(bad_rule):
|
||||||
|
from pydantic import ValidationError
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
FrontendConfig(
|
||||||
|
name="fe1", bind_port=80, mode="http", use_backend_rules=[bad_rule])
|
||||||
|
|
||||||
|
|
||||||
|
def test_contradiction_detection_still_works_on_file_flag_rules():
|
||||||
|
"""Interaction guard: a `-f` rule with an `X !X` contradiction is
|
||||||
|
still caught by the handler-level contradiction machinery — the
|
||||||
|
`-f` relaxation must not weaken that gate."""
|
||||||
|
from models.frontend import _frontend_has_acl_contradiction
|
||||||
|
assert _frontend_has_acl_contradiction(
|
||||||
|
"be1 if blacklisted !blacklisted") is True
|
||||||
|
# And a normal -f rule is NOT a contradiction.
|
||||||
|
assert _frontend_has_acl_contradiction(UB_F) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. WARNINGS — _pattern_file_warnings (zero-noise contract)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_file_warnings_lists_unique_paths():
|
||||||
|
warnings = _pattern_file_warnings(
|
||||||
|
acl_rules=[ACL_F, "other src -f /etc/haproxy/blacklist.lst"],
|
||||||
|
use_backend_rules=[UB_F],
|
||||||
|
redirect_rules=[{"condition": "if { src -f /etc/haproxy/geo.lst }"}],
|
||||||
|
)
|
||||||
|
assert len(warnings) == 1
|
||||||
|
w = warnings[0]
|
||||||
|
assert "/etc/haproxy/blacklist.lst" in w
|
||||||
|
assert "/etc/haproxy/allowlist.lst" in w
|
||||||
|
assert "/etc/haproxy/geo.lst" in w
|
||||||
|
# Duplicate path listed once.
|
||||||
|
assert w.count("/etc/haproxy/blacklist.lst") == 1
|
||||||
|
# Non-blocking framing: mentions fail-safe haproxy -c.
|
||||||
|
assert "haproxy -c" in w
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_file_warnings_empty_without_file_flag():
|
||||||
|
"""Zero-noise: operators who don't use `-f` must see NO warning."""
|
||||||
|
assert _pattern_file_warnings(
|
||||||
|
acl_rules=["is_api path_beg /api", "is_admin src 10.0.0.0/24"],
|
||||||
|
use_backend_rules=["be-api if is_api"],
|
||||||
|
redirect_rules=[{"type": "scheme", "scheme": "https",
|
||||||
|
"condition": "if !{ ssl_fc }"}],
|
||||||
|
) == []
|
||||||
|
assert _pattern_file_warnings() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_file_warnings_ignores_dash_f_substrings():
|
||||||
|
"""`-file`/`-foo` substrings must not trigger the advisory."""
|
||||||
|
assert _pattern_file_warnings(
|
||||||
|
acl_rules=["is_self path_beg /self-config-file",
|
||||||
|
"is_foo path_beg /foo -m beg"],
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# 4. Wizard models accept `-f` (string + dict) — parity
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_wizard_models_accept_file_flag():
|
||||||
|
from models.site_wizard import FrontendStep
|
||||||
|
|
||||||
|
fe = FrontendStep(
|
||||||
|
name="fe1", mode="http", bind_address="*", bind_port=80,
|
||||||
|
acl_rules=[ACL_F],
|
||||||
|
use_backend_rules=["be-x if blacklisted"],
|
||||||
|
redirect_rules=[{"type": "scheme", "target": "https",
|
||||||
|
"condition": "if { src -f /etc/haproxy/x.lst }"}],
|
||||||
|
)
|
||||||
|
assert fe.acl_rules == [ACL_F]
|
||||||
|
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/x.lst }"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Bulk-import preview advisory — source-level pin
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_bulk_advisory_scans_only_structured_rule_fields():
|
||||||
|
"""The preview advisory scans acl_rules/use_backend_rules but NOT
|
||||||
|
request_headers/tcp_request_rules (always-free-form fields —
|
||||||
|
warning there would add new noise for existing users)."""
|
||||||
|
src = Path(__file__).resolve().parents[1] / "routers" / "config.py"
|
||||||
|
text = src.read_text()
|
||||||
|
block_start = text.index("pattern-file advisory")
|
||||||
|
block = text[block_start:block_start + 1200]
|
||||||
|
assert 'acl_rules' in block
|
||||||
|
assert 'use_backend_rules' in block
|
||||||
|
assert 'request_headers' not in block.split("_pattern_paths")[1], (
|
||||||
|
"advisory must not scan request_headers")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dash_f_reject_left_in_models():
|
||||||
|
"""No model file may still hard-reject the `-f` flag."""
|
||||||
|
for rel in ("models/frontend.py", "models/site_wizard.py"):
|
||||||
|
text = (Path(__file__).resolve().parents[1] / rel).read_text()
|
||||||
|
for m in re.finditer(r"-f\(\\s\|\$\)", text):
|
||||||
|
ctx = text[max(0, m.start() - 400):m.start() + 400]
|
||||||
|
assert "raise ValueError" not in ctx, (
|
||||||
|
f"{rel}: a `-f` reject regex still sits next to a raise")
|
||||||
@@ -206,41 +206,38 @@ def test_module_level_validate_haproxy_config_forwards_partial_fragment():
|
|||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
# Wizard Pydantic gate: ACL `-f` flag must be REJECTED at submit.
|
# Issue #38 follow-up: ACL `-f <file>` pattern-file references are
|
||||||
|
# ACCEPTED (the Bulgu #12 hard reject was removed — pattern files are
|
||||||
|
# operator-managed host files, the agent's pre-reload `haproxy -c`
|
||||||
|
# makes a missing file fail safely, and bulk import always accepted
|
||||||
|
# `-f`). These tests pin the ACCEPT behaviour.
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_wizard_pydantic_rejects_acl_with_file_flag():
|
def test_wizard_pydantic_accepts_acl_with_file_flag():
|
||||||
"""Pre-fix the wizard's ACL string validator passed
|
"""Issue #38 follow-up — the wizard's ACL string validator must
|
||||||
`acl name path -i -m reg -f /path` straight through. Apply-time
|
ACCEPT `-f <file>` pattern-file references (Bulgu #12 reject
|
||||||
HAProxy `-c` then failed with "failed to open pattern file".
|
removed). Operators with large host-managed IP blacklists rely
|
||||||
Pin that the validator now rejects `-f` at submit.
|
on this in production.
|
||||||
"""
|
"""
|
||||||
from models.site_wizard import FrontendStep
|
from models.site_wizard import FrontendStep
|
||||||
|
|
||||||
# Minimal valid wizard frontend kwargs — only the offending
|
fe = FrontendStep(
|
||||||
# acl_rules entry should trigger the failure.
|
|
||||||
fe_kwargs = dict(
|
|
||||||
name="fe1",
|
name="fe1",
|
||||||
mode="http",
|
mode="http",
|
||||||
bind_address="*",
|
bind_address="*",
|
||||||
bind_port=80,
|
bind_port=80,
|
||||||
acl_rules=["acl1 path -i -m reg -f /path"],
|
acl_rules=["acl1 path -i -m reg -f /path"],
|
||||||
)
|
)
|
||||||
from pydantic import ValidationError
|
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"], (
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
"ACL `-f` rule must round-trip verbatim through the wizard model"
|
||||||
FrontendStep(**fe_kwargs)
|
|
||||||
msg = str(exc_info.value)
|
|
||||||
assert "-f" in msg or "pattern-file" in msg.lower(), (
|
|
||||||
f"Bulgu #12 regression: ACL -f flag must be rejected with a "
|
|
||||||
f"clear pattern-file error. Got: {msg}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"rule",
|
"rule",
|
||||||
[
|
[
|
||||||
# Various spacing / position variants the regex must catch.
|
# Various spacing / position variants must all be accepted.
|
||||||
"acl1 path -f /etc/haproxy/list",
|
"acl1 path -f /etc/haproxy/list",
|
||||||
"acl1 path -i -f /tmp/x.lst",
|
"acl1 path -i -f /tmp/x.lst",
|
||||||
"acl1 src -f /etc/haproxy/admins.lst",
|
"acl1 src -f /etc/haproxy/admins.lst",
|
||||||
@@ -249,23 +246,19 @@ def test_wizard_pydantic_rejects_acl_with_file_flag():
|
|||||||
"acl1 path -f",
|
"acl1 path -f",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_wizard_pydantic_rejects_acl_with_file_flag_variants(rule):
|
def test_wizard_pydantic_accepts_acl_with_file_flag_variants(rule):
|
||||||
"""Every spacing / position variant the operator might type must
|
"""Every spacing / position variant must be accepted verbatim
|
||||||
be rejected. Pinned defensively so the regex never accidentally
|
(Issue #38 follow-up — no `-f` shape may be rejected)."""
|
||||||
relaxes to "only matches trailing -f".
|
|
||||||
"""
|
|
||||||
from models.site_wizard import FrontendStep
|
from models.site_wizard import FrontendStep
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
fe_kwargs = dict(
|
fe = FrontendStep(
|
||||||
name="fe1",
|
name="fe1",
|
||||||
mode="http",
|
mode="http",
|
||||||
bind_address="*",
|
bind_address="*",
|
||||||
bind_port=80,
|
bind_port=80,
|
||||||
acl_rules=[rule],
|
acl_rules=[rule],
|
||||||
)
|
)
|
||||||
with pytest.raises(ValidationError):
|
assert fe.acl_rules == [rule]
|
||||||
FrontendStep(**fe_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
|
def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
|
||||||
@@ -291,42 +284,29 @@ def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
|
|||||||
assert len(fe.acl_rules) == 3
|
assert len(fe.acl_rules) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_manual_frontend_validator_rejects_acl_with_file_flag():
|
def test_manual_frontend_validator_accepts_acl_with_file_flag():
|
||||||
"""Parity check: the manual Frontend API
|
"""Parity check (Issue #38 follow-up): the manual Frontend API
|
||||||
(`models/frontend.py::validate_acl_rules`) must apply the same
|
(`models/frontend.py::validate_acl_rules`) must ACCEPT `-f`
|
||||||
`-f` rejection. Operators see consistent behaviour from both the
|
pattern-file references, same as the wizard and bulk import.
|
||||||
wizard and the per-entity frontend page.
|
|
||||||
"""
|
"""
|
||||||
from models.frontend import FrontendConfig
|
from models.frontend import FrontendConfig
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
fe = FrontendConfig(
|
||||||
FrontendConfig(
|
name="fe1",
|
||||||
name="fe1",
|
bind_port=80,
|
||||||
bind_port=80,
|
mode="http",
|
||||||
mode="http",
|
acl_rules=["acl1 path -i -m reg -f /path"],
|
||||||
acl_rules=["acl1 path -i -m reg -f /path"],
|
|
||||||
)
|
|
||||||
msg = str(exc_info.value)
|
|
||||||
assert "-f" in msg or "pattern-file" in msg.lower(), (
|
|
||||||
f"Manual frontend API parity regression: ACL -f flag must be "
|
|
||||||
f"rejected. Got: {msg}"
|
|
||||||
)
|
)
|
||||||
|
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"]
|
||||||
|
|
||||||
|
|
||||||
def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
|
def test_wizard_pydantic_accepts_structured_redirect_dict_with_file_flag():
|
||||||
"""Round-3 audit extension — structured redirect dicts (the
|
"""Issue #38 follow-up — structured redirect dicts carrying `-f`
|
||||||
alternative shape that `models/site_wizard.py::_validate_redirect_rules`
|
pattern-file references in `condition`/`target` are ACCEPTED
|
||||||
accepts alongside legacy strings) also flow through to
|
(the Bulgu #12 dict-aware reject was removed together with the
|
||||||
`services/haproxy_config.py::_format_redirect_rule` and emit
|
string-rule reject).
|
||||||
their `condition` / `target` verbatim into the rendered HAProxy
|
|
||||||
directive. Without the dict-aware reject the visual builder's
|
|
||||||
`-f` block could be bypassed by hand-crafting a dict payload
|
|
||||||
against the API — recreating the same `failed to open pattern
|
|
||||||
file` failure at apply time.
|
|
||||||
"""
|
"""
|
||||||
from models.site_wizard import FrontendStep, BackendStep
|
from models.site_wizard import FrontendStep
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
fe_kwargs = dict(
|
fe_kwargs = dict(
|
||||||
name="fe1",
|
name="fe1",
|
||||||
@@ -334,37 +314,32 @@ def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
|
|||||||
mode="http",
|
mode="http",
|
||||||
)
|
)
|
||||||
|
|
||||||
# `condition` carrying `-f` must be rejected.
|
# `condition` carrying `-f` is accepted.
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
fe = FrontendStep(
|
||||||
FrontendStep(
|
**fe_kwargs,
|
||||||
**fe_kwargs,
|
redirect_rules=[
|
||||||
redirect_rules=[
|
{
|
||||||
{
|
"type": "scheme",
|
||||||
"type": "scheme",
|
"target": "https",
|
||||||
"target": "https",
|
"condition": "if { src -f /etc/haproxy/admins.lst }",
|
||||||
"condition": "if { src -f /etc/haproxy/admins.lst }",
|
}
|
||||||
}
|
],
|
||||||
],
|
)
|
||||||
)
|
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/admins.lst }"
|
||||||
msg = str(exc_info.value)
|
|
||||||
assert "pattern-file" in msg.lower() or "-f" in msg, msg
|
|
||||||
|
|
||||||
# `target` carrying `-f` must also be rejected (defence-in-depth
|
# `target` carrying `-f` is accepted too.
|
||||||
# for hand-crafted payloads).
|
fe = FrontendStep(
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
**fe_kwargs,
|
||||||
FrontendStep(
|
redirect_rules=[
|
||||||
**fe_kwargs,
|
{
|
||||||
redirect_rules=[
|
"type": "location",
|
||||||
{
|
"target": "/foo -f /tmp/x.lst",
|
||||||
"type": "location",
|
}
|
||||||
"target": "/foo -f /tmp/x.lst",
|
],
|
||||||
}
|
)
|
||||||
],
|
assert fe.redirect_rules[0]["target"] == "/foo -f /tmp/x.lst"
|
||||||
)
|
|
||||||
msg = str(exc_info.value)
|
|
||||||
assert "pattern-file" in msg.lower() or "-f" in msg, msg
|
|
||||||
|
|
||||||
# Clean structured dict still passes — no false positive.
|
# Clean structured dict still passes.
|
||||||
FrontendStep(
|
FrontendStep(
|
||||||
**fe_kwargs,
|
**fe_kwargs,
|
||||||
redirect_rules=[
|
redirect_rules=[
|
||||||
@@ -667,8 +642,8 @@ def test_user_reported_wizard_config_emits_no_false_warnings():
|
|||||||
zero WARNINGs from the directives we expanded.
|
zero WARNINGs from the directives we expanded.
|
||||||
"""
|
"""
|
||||||
# Distilled from the user's bulk-site-create snapshot, minus the
|
# Distilled from the user's bulk-site-create snapshot, minus the
|
||||||
# `-f` ACL (which the new Pydantic gate rejects before this
|
# `-f` ACL (accepted since the Issue #38 follow-up, but irrelevant
|
||||||
# validator ever runs).
|
# to the directive-expansion warnings this test pins).
|
||||||
config = """# ─── Wizard candidate fragment (dry-run preview) ───
|
config = """# ─── Wizard candidate fragment (dry-run preview) ───
|
||||||
frontend fe-site1
|
frontend fe-site1
|
||||||
bind *:80
|
bind *:80
|
||||||
|
|||||||
@@ -276,14 +276,16 @@ def test_categorize_routes_directives_correctly():
|
|||||||
|
|
||||||
def test_emit_buckets_flushed_in_canonical_order():
|
def test_emit_buckets_flushed_in_canonical_order():
|
||||||
"""The flush block at end of frontend processing must list buckets
|
"""The flush block at end of frontend processing must list buckets
|
||||||
in: prelude → stick → tcp_req → acl → http_req → http_resp →
|
in: prelude → filter → stick → tcp_req → acl → http_req → http_resp →
|
||||||
redirect → use_be → default_be. Pre-fix `http-request` rules
|
redirect → use_be → default_be. Pre-fix `http-request` rules
|
||||||
interleaved with `use_backend` rules in source order, producing
|
interleaved with `use_backend` rules in source order, producing
|
||||||
HAProxy parser warnings."""
|
HAProxy parser warnings. (Issue #38 added the `filter` bucket, flushed
|
||||||
|
right after `prelude` so SPOE `filter` lines precede `send-spoe-group`.)"""
|
||||||
src = _gen_src()
|
src = _gen_src()
|
||||||
flush_match = re.search(
|
flush_match = re.search(
|
||||||
r'for\s+_bucket_key\s+in\s+\(\s*'
|
r'for\s+_bucket_key\s+in\s+\(\s*'
|
||||||
r'"prelude"\s*,\s*'
|
r'"prelude"\s*,\s*'
|
||||||
|
r'"filter"\s*,\s*'
|
||||||
r'"stick"\s*,\s*'
|
r'"stick"\s*,\s*'
|
||||||
r'"tcp_req"\s*,\s*'
|
r'"tcp_req"\s*,\s*'
|
||||||
r'"acl"\s*,\s*'
|
r'"acl"\s*,\s*'
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Issue #38 regression tests: HAProxy SPOE `filter` + frontend `log-format` support.
|
||||||
|
|
||||||
|
Bug: the bulk-config parser recognised only a fixed set of frontend directives,
|
||||||
|
so `filter spoe engine coraza config ...` and `log-format ...` were silently
|
||||||
|
dropped on import / manual edit. This regenerated a config missing the SPOE
|
||||||
|
engine definition, so HAProxy failed with
|
||||||
|
"unable to find SPOE engine 'coraza' used by the send-spoe-group 'coraza-req'".
|
||||||
|
|
||||||
|
These tests verify the end-to-end fix without requiring a database:
|
||||||
|
1. parser captures `filter` + `log-format` into the new ParsedFrontend fields;
|
||||||
|
2. `http-request send-spoe-group` is still preserved (regression guard);
|
||||||
|
3. the generator's directive categoriser + bucket flush order emit `filter`
|
||||||
|
BEFORE the `http-request send-spoe-group` rules and keep `log-format`;
|
||||||
|
4. reject/rollback restores the new columns;
|
||||||
|
5. a non-SPOE frontend is completely unaffected (zero-impact).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from utils.haproxy_config_parser import parse_haproxy_config, ParsedFrontend
|
||||||
|
from services.haproxy_config import _categorize_haproxy_directive
|
||||||
|
from models.frontend import FrontendConfig
|
||||||
|
|
||||||
|
|
||||||
|
# The exact frontend/backend config reported in Issue #38 (Coraza-SPOA).
|
||||||
|
ISSUE_38_CONFIG = r"""
|
||||||
|
frontend web-frontend
|
||||||
|
bind *:8073
|
||||||
|
mode http
|
||||||
|
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %ST\ %B\ %{+Q}r\ %[var(txn.coraza.id)]\ waf-hit:\ %[var(txn.coraza.fail)]"
|
||||||
|
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||||
|
http-request set-var(txn.coraza.app) str(haproxy_waf)
|
||||||
|
http-request send-spoe-group coraza coraza-req
|
||||||
|
http-request deny if { var(txn.coraza.fail) -m int eq 1 }
|
||||||
|
default_backend web-backend
|
||||||
|
|
||||||
|
backend web-backend
|
||||||
|
balance roundrobin
|
||||||
|
mode http
|
||||||
|
server server1 192.168.1.10:443 weight 100 ssl verify none
|
||||||
|
|
||||||
|
backend coraza-spoa
|
||||||
|
mode tcp
|
||||||
|
option spop-check
|
||||||
|
server coraza_spoa 192.168.12.21:9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_frontend(parse_result, name):
|
||||||
|
for fe in parse_result.frontends:
|
||||||
|
if fe.name == name:
|
||||||
|
return fe
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParserCapturesSpoe:
|
||||||
|
def test_filter_and_log_format_captured(self):
|
||||||
|
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||||
|
fe = _get_frontend(result, "web-frontend")
|
||||||
|
assert fe is not None, "web-frontend should be parsed and kept"
|
||||||
|
assert fe.filters is not None
|
||||||
|
assert "filter spoe engine coraza config /etc/haproxy/coraza.cfg" in fe.filters
|
||||||
|
assert fe.log_format is not None
|
||||||
|
assert fe.log_format.startswith("log-format")
|
||||||
|
# the escaped/quoted format string must be preserved verbatim
|
||||||
|
assert "%[var(txn.coraza.fail)]" in fe.log_format
|
||||||
|
|
||||||
|
def test_send_spoe_group_still_preserved(self):
|
||||||
|
# Regression guard: http-request rules (incl. send-spoe-group) must
|
||||||
|
# still be collected into request_headers as before.
|
||||||
|
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||||
|
fe = _get_frontend(result, "web-frontend")
|
||||||
|
assert fe.request_headers is not None
|
||||||
|
assert "send-spoe-group coraza coraza-req" in fe.request_headers
|
||||||
|
|
||||||
|
def test_multiple_filters_preserved_in_order(self):
|
||||||
|
cfg = """
|
||||||
|
frontend f1
|
||||||
|
bind *:80
|
||||||
|
mode http
|
||||||
|
filter compression
|
||||||
|
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||||
|
default_backend b1
|
||||||
|
|
||||||
|
backend b1
|
||||||
|
mode http
|
||||||
|
server s1 10.0.0.1:80
|
||||||
|
"""
|
||||||
|
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||||
|
lines = fe.filters.split("\n")
|
||||||
|
assert lines == [
|
||||||
|
"filter compression",
|
||||||
|
"filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_log_format_sd_variant_captured(self):
|
||||||
|
cfg = """
|
||||||
|
frontend f1
|
||||||
|
bind *:80
|
||||||
|
mode http
|
||||||
|
log-format-sd "[exampleSDID@1234 field=value]"
|
||||||
|
default_backend b1
|
||||||
|
|
||||||
|
backend b1
|
||||||
|
mode http
|
||||||
|
server s1 10.0.0.1:80
|
||||||
|
"""
|
||||||
|
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||||
|
assert fe.log_format is not None
|
||||||
|
assert fe.log_format.startswith("log-format-sd")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneratorOrderingContract:
|
||||||
|
"""The generator routes directives into ordered buckets. Verify SPOE
|
||||||
|
correctness at the (pure) categoriser + documented flush-order level."""
|
||||||
|
|
||||||
|
def test_filter_routes_to_filter_bucket(self):
|
||||||
|
assert _categorize_haproxy_directive(" filter spoe engine coraza config /x.cfg") == "filter"
|
||||||
|
|
||||||
|
def test_send_spoe_group_routes_to_http_req(self):
|
||||||
|
assert _categorize_haproxy_directive(" http-request send-spoe-group coraza coraza-req") == "http_req"
|
||||||
|
|
||||||
|
def test_log_format_routes_to_prelude(self):
|
||||||
|
assert _categorize_haproxy_directive(' log-format "%ci:%cp"') == "prelude"
|
||||||
|
assert _categorize_haproxy_directive(' log-format-sd "[x]"') == "prelude"
|
||||||
|
|
||||||
|
def test_flush_order_places_filter_before_http_req(self):
|
||||||
|
# The bucket flush order is the single source of truth for emission
|
||||||
|
# ordering. Assert `filter` is flushed before `http_req` (and after
|
||||||
|
# `prelude`), guaranteeing `filter ...` renders before
|
||||||
|
# `http-request send-spoe-group ...`.
|
||||||
|
src = _read_source("services/haproxy_config.py")
|
||||||
|
m = re.search(r"for _bucket_key in \((.*?)\):", src, re.DOTALL)
|
||||||
|
assert m, "bucket flush loop not found"
|
||||||
|
order = re.findall(r'"(\w+)"', m.group(1))
|
||||||
|
assert "filter" in order, "new 'filter' bucket missing from flush order"
|
||||||
|
assert order.index("prelude") < order.index("filter") < order.index("http_req")
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelAndRollback:
|
||||||
|
def test_model_has_passthrough_fields(self):
|
||||||
|
fc = FrontendConfig(
|
||||||
|
name="f", bind_port=80,
|
||||||
|
filters="filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||||
|
log_format='log-format "%ci"',
|
||||||
|
)
|
||||||
|
assert fc.filters.startswith("filter spoe")
|
||||||
|
assert fc.log_format.startswith("log-format")
|
||||||
|
|
||||||
|
def test_dataclass_defaults_none(self):
|
||||||
|
fe = ParsedFrontend(name="f")
|
||||||
|
assert fe.filters is None
|
||||||
|
assert fe.log_format is None
|
||||||
|
|
||||||
|
def test_rollback_restores_new_columns(self):
|
||||||
|
# Reject/rollback of a frontend UPDATE must restore the new columns,
|
||||||
|
# otherwise the rejected (new) filters/log_format would persist.
|
||||||
|
src = _read_source("utils/entity_snapshot.py")
|
||||||
|
assert "log_format = $" in src
|
||||||
|
assert "filters = $" in src
|
||||||
|
assert "old_values.get('log_format')" in src
|
||||||
|
assert "old_values.get('filters')" in src
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeroImpact:
|
||||||
|
def test_non_spoe_frontend_unaffected(self):
|
||||||
|
cfg = """
|
||||||
|
frontend plain
|
||||||
|
bind *:80
|
||||||
|
mode http
|
||||||
|
option httplog
|
||||||
|
default_backend b1
|
||||||
|
|
||||||
|
backend b1
|
||||||
|
mode http
|
||||||
|
server s1 10.0.0.1:80
|
||||||
|
"""
|
||||||
|
fe = _get_frontend(parse_haproxy_config(cfg), "plain")
|
||||||
|
# No filter / log-format present → new fields stay None (no behaviour change)
|
||||||
|
assert fe.filters is None
|
||||||
|
assert fe.log_format is None
|
||||||
|
|
||||||
|
def test_spop_check_backend_roundtrips_without_warning(self):
|
||||||
|
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||||
|
be = next((b for b in result.backends if b.name == "coraza-spoa"), None)
|
||||||
|
assert be is not None, "coraza-spoa backend should import"
|
||||||
|
assert be.mode == "tcp"
|
||||||
|
assert be.options and "option spop-check" in be.options
|
||||||
|
# spop-check is now a known option → no spurious 'unknown option' warning
|
||||||
|
assert not any(
|
||||||
|
"coraza-spoa" in w and "spop-check" in w and "Unknown" in w
|
||||||
|
for w in result.warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_source(relpath):
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
with open(os.path.join(base, relpath), "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
@@ -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}"
|
||||||
@@ -330,8 +330,9 @@ async def _rollback_update(
|
|||||||
rate_limit = $29, compression = $30, log_separate = $31,
|
rate_limit = $29, compression = $30, log_separate = $31,
|
||||||
monitor_uri = $32, maxconn = $33,
|
monitor_uri = $32, maxconn = $33,
|
||||||
cluster_id = $34, is_active = $35, last_config_status = $36,
|
cluster_id = $34, is_active = $35, last_config_status = $36,
|
||||||
|
log_format = $37, filters = $38,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $37
|
WHERE id = $39
|
||||||
""",
|
""",
|
||||||
old_values.get('name'),
|
old_values.get('name'),
|
||||||
old_values.get('bind_address'),
|
old_values.get('bind_address'),
|
||||||
@@ -369,6 +370,8 @@ async def _rollback_update(
|
|||||||
old_values.get('cluster_id'),
|
old_values.get('cluster_id'),
|
||||||
old_values.get('is_active'),
|
old_values.get('is_active'),
|
||||||
old_values.get('last_config_status'),
|
old_values.get('last_config_status'),
|
||||||
|
old_values.get('log_format'), # Issue #38
|
||||||
|
old_values.get('filters'), # Issue #38
|
||||||
entity_id
|
entity_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class ParsedFrontend:
|
|||||||
response_headers: Optional[str] = None
|
response_headers: Optional[str] = None
|
||||||
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
|
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
|
||||||
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
|
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
|
||||||
|
# Issue #38: SPOE (and other) filter directives + frontend log-format.
|
||||||
|
# Stored as full directive lines; `filters` is newline-joined to preserve
|
||||||
|
# ordering when multiple `filter ...` lines exist.
|
||||||
|
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
|
||||||
|
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -256,8 +261,23 @@ class HAProxyConfigParser:
|
|||||||
acl_rules_list = []
|
acl_rules_list = []
|
||||||
use_backend_rules_list = []
|
use_backend_rules_list = []
|
||||||
tcp_request_rules_list = []
|
tcp_request_rules_list = []
|
||||||
|
filters_list = []
|
||||||
|
log_format_list = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
|
||||||
|
# `log-format`/`log-format-sd` directives. Pre-fix these matched
|
||||||
|
# no branch below and were silently dropped, so an imported SPOE
|
||||||
|
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
|
||||||
|
# "unable to find SPOE engine") and the frontend log-format.
|
||||||
|
# `continue` isolates them from the header/option handling below.
|
||||||
|
if line.startswith('filter '):
|
||||||
|
filters_list.append(line.strip())
|
||||||
|
continue
|
||||||
|
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
|
||||||
|
log_format_list.append(line.strip())
|
||||||
|
continue
|
||||||
|
|
||||||
# Parse bind directive
|
# Parse bind directive
|
||||||
# IMPORTANT: Handle multiple bind lines correctly
|
# IMPORTANT: Handle multiple bind lines correctly
|
||||||
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
|
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
|
||||||
@@ -540,6 +560,13 @@ class HAProxyConfigParser:
|
|||||||
if tcp_request_rules_list:
|
if tcp_request_rules_list:
|
||||||
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
|
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
|
||||||
|
|
||||||
|
# Issue #38: assign captured SPOE filters + log-format
|
||||||
|
if filters_list:
|
||||||
|
frontend.filters = '\n'.join(filters_list)
|
||||||
|
|
||||||
|
if log_format_list:
|
||||||
|
frontend.log_format = '\n'.join(log_format_list)
|
||||||
|
|
||||||
self.frontends.append(frontend)
|
self.frontends.append(frontend)
|
||||||
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
|
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
|
||||||
|
|
||||||
@@ -666,7 +693,12 @@ class HAProxyConfigParser:
|
|||||||
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
||||||
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
||||||
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
||||||
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
|
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
|
||||||
|
# Issue #38: SPOP health check for SPOE agent backends
|
||||||
|
# (e.g. coraza-spoa). Already collected below regardless, but
|
||||||
|
# listing it suppresses the spurious "unknown option" warning
|
||||||
|
# for the exact SPOE use-case.
|
||||||
|
'spop-check'
|
||||||
]
|
]
|
||||||
|
|
||||||
if option_name not in valid_options:
|
if option_name not in valid_options:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "1.8.9",
|
||||||
|
"releaseName": "ACL -f pattern-file support (Issue #38 follow-up)",
|
||||||
|
"releaseDate": "2026-07-13"
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- haproxy_configs:/etc/haproxy
|
- haproxy_configs:/etc/haproxy
|
||||||
- ./version.json:/app/version.json:ro
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: haproxy-openmanager-frontend:localtest
|
image: haproxy-openmanager-frontend:localtest
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ services:
|
|||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
- PUBLIC_URL=http://localhost:8080
|
- PUBLIC_URL=http://localhost:8080
|
||||||
- MANAGEMENT_BASE_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:
|
volumes:
|
||||||
- haproxy_configs:/etc/haproxy
|
- haproxy_configs:/etc/haproxy
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "haproxy-openmanager-frontend",
|
"name": "haproxy-openmanager-frontend",
|
||||||
"version": "1.8.5",
|
"version": "1.8.9",
|
||||||
"description": "HAProxy Load Balancer Management UI",
|
"description": "HAProxy Load Balancer Management UI",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -53,19 +53,19 @@ const MATCH_TYPE_GROUPS = [
|
|||||||
{ label: 'Advanced', options: MATCH_TYPES.filter(m => m.category === 'Advanced') },
|
{ label: 'Advanced', options: MATCH_TYPES.filter(m => m.category === 'Advanced') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — the `-f <file>`
|
// Issue #38 follow-up — `-f <file>` is back in the visual builder:
|
||||||
// flag was removed from the visual builder because HAProxy OpenManager
|
// the Bulgu #12 removal (and the matching Pydantic rejects) assumed a
|
||||||
// does not provision pattern files onto the HAProxy node filesystem.
|
// missing pattern file would surprise the operator at apply time, but
|
||||||
// Allowing `-f` in the visual builder produced ACL rules that passed
|
// the agent runs `haproxy -c` before every reload so a missing file
|
||||||
// every UI / Pydantic / heuristic check but ALWAYS failed HAProxy's
|
// fails safely (previous config keeps running), and bulk import plus
|
||||||
// real `-c` parse at apply time with "failed to open pattern file".
|
// the free-form fields always accepted `-f`. Pattern files are
|
||||||
// Operators reported a multi-page wizard run ending at the Apply
|
// operator-managed host files, same policy as SPOE filter configs
|
||||||
// Management red-badge for a footgun the UI made trivial to step on.
|
// (v1.8.8). The value field carries the file path (e.g. flag `-f`
|
||||||
// The Pydantic validators on the manual API + wizard reject `-f`
|
// + value `/etc/haproxy/blacklist.lst`); an informational note is
|
||||||
// universally; the visual builder simply removes the option from the
|
// rendered on rules that use it.
|
||||||
// dropdown so operators cannot author the unsupported state.
|
|
||||||
const FLAGS = [
|
const FLAGS = [
|
||||||
{ value: '-i', label: '-i (case insensitive)' },
|
{ value: '-i', label: '-i (case insensitive)' },
|
||||||
|
{ value: '-f', label: '-f (pattern file on host)' },
|
||||||
{ value: '-m beg', label: '-m beg (begins with)' },
|
{ value: '-m beg', label: '-m beg (begins with)' },
|
||||||
{ value: '-m end', label: '-m end (ends with)' },
|
{ value: '-m end', label: '-m end (ends with)' },
|
||||||
{ value: '-m sub', label: '-m sub (contains)' },
|
{ value: '-m sub', label: '-m sub (contains)' },
|
||||||
@@ -396,25 +396,23 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
|||||||
};
|
};
|
||||||
const isRaw = rule.raw !== undefined;
|
const isRaw = rule.raw !== undefined;
|
||||||
|
|
||||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — surface `-f` flag
|
// Issue #38 follow-up — `-f <file>` pattern-file references are
|
||||||
// usage inline. The Pydantic validator rejects the rule server-side,
|
// ACCEPTED now (the Bulgu #12 reject was removed server-side too).
|
||||||
// but operators benefit from seeing the error AS they type / when
|
// We still detect them, but only to render an informational note:
|
||||||
// they re-open a draft that carries a `-f`-flagged rule (e.g. from
|
// the referenced file is operator-managed and must exist on every
|
||||||
// a pre-fix draft). The error message matches the Pydantic error
|
// HAProxy host; a missing file fails safely at the agent's
|
||||||
// verbatim so support flows are consistent.
|
// pre-reload `haproxy -c`.
|
||||||
const rawHasFileFlag = isRaw && typeof rule.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(rule.raw);
|
const rawHasFileFlag = isRaw && typeof rule.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(rule.raw);
|
||||||
const structuredHasFileFlag =
|
const structuredHasFileFlag =
|
||||||
!isRaw && Array.isArray(rule.flags) && rule.flags.includes('-f');
|
!isRaw && Array.isArray(rule.flags) && rule.flags.includes('-f');
|
||||||
const hasFileFlag = rawHasFileFlag || structuredHasFileFlag;
|
const hasFileFlag = rawHasFileFlag || structuredHasFileFlag;
|
||||||
const cardStyleWithError = hasFileFlag
|
const cardStyleWithError = ruleCardStyle;
|
||||||
? { ...ruleCardStyle, border: `1px solid ${token.colorError}` }
|
|
||||||
: ruleCardStyle;
|
|
||||||
const FILE_FLAG_TOOLTIP =
|
const FILE_FLAG_TOOLTIP =
|
||||||
"ACL pattern-file references (-f <file>) are not supported by "
|
"This rule references a pattern file (-f <file>). The file must "
|
||||||
+ "HAProxy OpenManager: the product does not provision pattern "
|
+ "exist at that exact path on every HAProxy host in the cluster — "
|
||||||
+ "files onto the HAProxy node filesystem, so the reference "
|
+ "HAProxy OpenManager does not create or distribute pattern files. "
|
||||||
+ "would fail at HAProxy reload time. Remove '-f' and use inline "
|
+ "A missing file fails safely at 'haproxy -c' (the previous config "
|
||||||
+ "values instead.";
|
+ "keeps running).";
|
||||||
|
|
||||||
if (isRaw) {
|
if (isRaw) {
|
||||||
return (
|
return (
|
||||||
@@ -427,11 +425,10 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
|||||||
onChange={(e) => onChange(index, { raw: e.target.value })}
|
onChange={(e) => onChange(index, { raw: e.target.value })}
|
||||||
placeholder="Raw ACL rule (e.g. my_acl path_beg /api)"
|
placeholder="Raw ACL rule (e.g. my_acl path_beg /api)"
|
||||||
prefix={<Tag color="default" style={{ marginRight: 4 }}>RAW</Tag>}
|
prefix={<Tag color="default" style={{ marginRight: 4 }}>RAW</Tag>}
|
||||||
status={hasFileFlag ? 'error' : undefined}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{hasFileFlag && (
|
{hasFileFlag && (
|
||||||
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||||
{FILE_FLAG_TOOLTIP}
|
{FILE_FLAG_TOOLTIP}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -549,12 +546,11 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
|||||||
onChange={(e) => onChange(index, { ...rule, value: e.target.value })}
|
onChange={(e) => onChange(index, { ...rule, value: e.target.value })}
|
||||||
placeholder={matchDef?.placeholder || 'Value'}
|
placeholder={matchDef?.placeholder || 'Value'}
|
||||||
size="small"
|
size="small"
|
||||||
status={structuredHasFileFlag ? 'error' : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{structuredHasFileFlag && (
|
{structuredHasFileFlag && (
|
||||||
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||||
{FILE_FLAG_TOOLTIP}
|
{FILE_FLAG_TOOLTIP}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -891,11 +887,11 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
|
|||||||
.map(d => d.name);
|
.map(d => d.name);
|
||||||
}, [aclDefs]);
|
}, [aclDefs]);
|
||||||
|
|
||||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — count rules that
|
// Issue #38 follow-up — count rules that reference a `-f <file>`
|
||||||
// still carry the unsupported `-f <file>` flag. Surfaced as a
|
// pattern file. Surfaced as a section-level informational Alert
|
||||||
// section-level Alert so operators know the section as a whole
|
// (non-blocking): the file is operator-managed and must exist on
|
||||||
// has invalid rules even if individual cards / raw text would
|
// every HAProxy host; a missing file fails safely at the agent's
|
||||||
// otherwise need scrolling to find them.
|
// pre-reload `haproxy -c`.
|
||||||
const fileFlagRuleCount = useMemo(() => {
|
const fileFlagRuleCount = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const d of aclDefs) {
|
for (const d of aclDefs) {
|
||||||
@@ -1153,19 +1149,19 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
|
|||||||
Define named conditions to match incoming requests by path, header, source IP, and more.
|
Define named conditions to match incoming requests by path, header, source IP, and more.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Phase K Phase D follow-up (Bulgu #12 round 3) — section-
|
{/* Issue #38 follow-up — section-level informational note
|
||||||
level warning when one or more rules still carry the
|
when one or more rules reference `-f <file>` pattern
|
||||||
unsupported `-f <file>` pattern-file flag. Render as a
|
files. Non-blocking: pattern files are operator-managed
|
||||||
blocking-style Alert so the operator notices BEFORE
|
host files (the Bulgu #12 reject was removed) and a
|
||||||
Submit. The Pydantic validator rejects the same shape
|
missing file fails safely at the agent's pre-reload
|
||||||
server-side; this is the up-front authoring guardrail. */}
|
`haproxy -c`. */}
|
||||||
{fileFlagRuleCount > 0 && (
|
{fileFlagRuleCount > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} use the unsupported \`-f <file>\` flag`}
|
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} reference a \`-f <file>\` pattern file`}
|
||||||
description="HAProxy OpenManager does not provision pattern files onto the HAProxy node filesystem, so any `-f /path/...` reference would fail HAProxy reload at apply time with 'failed to open pattern file'. Remove the `-f` flag and switch to inline values (e.g. `src 10.0.0.0/24` instead of `src -f /etc/haproxy/admins.lst`)."
|
description="The referenced file must exist at that exact path on every HAProxy host in the cluster — HAProxy OpenManager does not create or distribute pattern files. A missing file fails safely at 'haproxy -c' (the previous config keeps running)."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ backend web-backend
|
|||||||
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
|
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
|
||||||
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
|
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
|
||||||
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
|
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
|
||||||
|
{record.filters && <Tag color="magenta">Filters</Tag>}
|
||||||
|
{record.log_format && <Tag color="geekblue">Log Format</Tag>}
|
||||||
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
|
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
|
||||||
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
|
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -1078,6 +1080,7 @@ backend web-backend
|
|||||||
expandedRowRender: (frontend) => {
|
expandedRowRender: (frontend) => {
|
||||||
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
||||||
frontend.options || frontend.tcp_request_rules ||
|
frontend.options || frontend.tcp_request_rules ||
|
||||||
|
frontend.filters || frontend.log_format ||
|
||||||
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
|
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
|
||||||
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
|
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
|
||||||
|
|
||||||
@@ -1163,6 +1166,46 @@ backend web-backend
|
|||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
|
{/* Issue #38: SPOE filters */}
|
||||||
|
{frontend.filters && (
|
||||||
|
<Descriptions.Item
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Filters (SPOE/WAF)
|
||||||
|
{frontend._changes?.filters && (
|
||||||
|
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||||
|
{frontend._changes.filters.old ? 'CHANGED' : 'NEW'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MultiLineDiffRenderer
|
||||||
|
value={frontend.filters}
|
||||||
|
changeInfo={frontend._changes?.filters}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{/* Issue #38: frontend log-format */}
|
||||||
|
{frontend.log_format && (
|
||||||
|
<Descriptions.Item
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
Log Format
|
||||||
|
{frontend._changes?.log_format && (
|
||||||
|
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||||
|
{frontend._changes.log_format.old ? 'CHANGED' : 'NEW'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MultiLineDiffRenderer
|
||||||
|
value={frontend.log_format}
|
||||||
|
changeInfo={frontend._changes?.log_format}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
|
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
|
||||||
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
|
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
|
||||||
{frontend.acl_rules.map((acl, idx) => (
|
{frontend.acl_rules.map((acl, idx) => (
|
||||||
|
|||||||
@@ -642,7 +642,11 @@ const FrontendManagement = () => {
|
|||||||
// Explicitly set options field to handle null/undefined case (NEW field)
|
// Explicitly set options field to handle null/undefined case (NEW field)
|
||||||
options: frontend.options || '',
|
options: frontend.options || '',
|
||||||
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
|
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
|
||||||
tcp_request_rules: frontend.tcp_request_rules || ''
|
tcp_request_rules: frontend.tcp_request_rules || '',
|
||||||
|
// Issue #38: SPOE filters + frontend log-format (null → '' so the
|
||||||
|
// TextAreas populate on edit and round-trip on save, preventing null-wipe)
|
||||||
|
log_format: frontend.log_format || '',
|
||||||
|
filters: frontend.filters || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update SSL field visibility after setting values
|
// Update SSL field visibility after setting values
|
||||||
@@ -862,31 +866,13 @@ const FrontendManagement = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — hard-gate any
|
// Issue #38 follow-up — the Bulgu #12 client-side hard gate for
|
||||||
// ACL / use_backend / redirect rule that carries the unsupported
|
// the ACL `-f <file>` pattern-file flag was removed together with
|
||||||
// HAProxy `-f <file>` pattern-file flag. The Pydantic validator
|
// the server-side Pydantic rejects: pattern files are operator-
|
||||||
// on the backend (`models/frontend.py::validate_acl_rules`)
|
// managed host files (same policy as SPOE filter configs since
|
||||||
// rejects the same shape; blocking here surfaces the error
|
// v1.8.8) and the agent's pre-reload `haproxy -c` makes a missing
|
||||||
// immediately at the manual frontend form and matches the wizard
|
// file fail safely. The server response now carries a non-blocking
|
||||||
// gate so operators see consistent behaviour between the two
|
// warning listing the referenced files (rendered below).
|
||||||
// entry points.
|
|
||||||
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
|
|
||||||
const aclRulesAll = [
|
|
||||||
...(aclBuilderData.aclRules || []),
|
|
||||||
...(aclBuilderData.useBackendRules || []),
|
|
||||||
...(aclBuilderData.redirectRules || []).map(
|
|
||||||
(r) => (typeof r === 'string' ? r : ''),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
|
|
||||||
message.error(
|
|
||||||
'One or more ACL / routing / redirect rules use the unsupported HAProxy ' +
|
|
||||||
'`-f <file>` pattern-file flag. HAProxy OpenManager does not provision ' +
|
|
||||||
'pattern files onto the HAProxy node filesystem, so the reference would ' +
|
|
||||||
'fail at reload time. Remove the `-f` flag and use inline values instead.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase K Phase D follow-up (Bulgu #13) — gate for
|
// Phase K Phase D follow-up (Bulgu #13) — gate for
|
||||||
// self-contradictory routing / redirect conditions (`X !X`).
|
// self-contradictory routing / redirect conditions (`X !X`).
|
||||||
@@ -1178,6 +1164,29 @@ const FrontendManagement = () => {
|
|||||||
} else {
|
} else {
|
||||||
message.success('Frontend created successfully');
|
message.success('Frontend created successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #38 follow-up — surface server-emitted warnings on
|
||||||
|
// CREATE too (e.g. the `-f <file>` pattern-file advisory).
|
||||||
|
// Mirrors the update-branch rendering above.
|
||||||
|
const createWarnings = Array.isArray(response.data?.warnings)
|
||||||
|
? response.data.warnings
|
||||||
|
: [];
|
||||||
|
if (createWarnings.length > 0) {
|
||||||
|
message.warning(
|
||||||
|
<div>
|
||||||
|
<div><strong>Frontend saved, but the server flagged {createWarnings.length} rule warning(s):</strong></div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: '12px', fontFamily: 'monospace' }}>
|
||||||
|
{createWarnings.slice(0, 5).map((w, i) => (
|
||||||
|
<div key={i}>• {w.length > 240 ? `${w.slice(0, 237)}...` : w}</div>
|
||||||
|
))}
|
||||||
|
{createWarnings.length > 5 && (
|
||||||
|
<div>(+{createWarnings.length - 5} more)</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
@@ -2250,6 +2259,40 @@ tcp-request connection reject if { src -f /etc/haproxy/blacklist.lst }`}
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Issue #38: SPOE filters + frontend log-format */}
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item
|
||||||
|
name="filters"
|
||||||
|
label="Filters (SPOE / WAF)"
|
||||||
|
extra="HAProxy filter directives (one per line). Emitted before send-spoe-group rules."
|
||||||
|
tooltip="e.g. Coraza WAF via SPOE. The referenced engine config file and its SPOA backend must exist on the HAProxy host."
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder={`Examples:
|
||||||
|
filter spoe engine coraza config /etc/haproxy/coraza.cfg`}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item
|
||||||
|
name="log_format"
|
||||||
|
label="Custom Log Format"
|
||||||
|
extra="HAProxy log-format / log-format-sd directive (kept verbatim)"
|
||||||
|
tooltip="Overrides option httplog/tcplog. Use the full directive including the quoted format string."
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder={'log-format "%ci:%cp [%t] %ft %b/%s %ST %B %{+Q}r"'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
|
|||||||
@@ -3177,36 +3177,15 @@ const SiteWizard = () => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Phase K Phase D follow-up (Bulgu #12 round 3) —
|
// Issue #38 follow-up — the Bulgu #12 Step 2 → 3
|
||||||
// hard-gate the Step 2 → Step 3 advance on any ACL
|
// hard gate for the ACL `-f <file>` pattern-file
|
||||||
// rule that carries the unsupported `-f <file>`
|
// flag was removed together with the server-side
|
||||||
// pattern-file flag. The Pydantic validator rejects
|
// Pydantic rejects: pattern files are operator-
|
||||||
// the same shape at submit, but blocking the Next
|
// managed host files (same policy as SPOE filter
|
||||||
// button here surfaces the error immediately at
|
// configs since v1.8.8) and the agent's pre-reload
|
||||||
// its source step (the ACL builder is right above)
|
// `haproxy -c` makes a missing file fail safely.
|
||||||
// instead of bouncing the operator from Step 4's
|
// The ACLRuleBuilder renders an informational note
|
||||||
// dry-run card back to Step 2 with a less-specific
|
// on `-f` rules instead of a blocking error.
|
||||||
// jumpback button. The ACLRuleBuilder ALSO renders
|
|
||||||
// a section-level red Alert when this state is
|
|
||||||
// active so the operator already sees what to fix.
|
|
||||||
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
|
|
||||||
const aclRulesAll = [
|
|
||||||
...(aclBuilderData.aclRules || []),
|
|
||||||
...(aclBuilderData.useBackendRules || []),
|
|
||||||
...(aclBuilderData.redirectRules || []).map(
|
|
||||||
(r) => (typeof r === 'string' ? r : ''),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
|
|
||||||
message.error(
|
|
||||||
'One or more rules use the unsupported HAProxy `-f <file>` ' +
|
|
||||||
'pattern-file flag. HAProxy OpenManager does not provision ' +
|
|
||||||
'pattern files onto the HAProxy node filesystem, so the ' +
|
|
||||||
'reference would fail at reload time. Remove the `-f` flag ' +
|
|
||||||
'and use inline values instead before continuing.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Phase K Phase D follow-up (Bulgu #13) — block
|
// Phase K Phase D follow-up (Bulgu #13) — block
|
||||||
// advance when any routing / redirect rule has a
|
// advance when any routing / redirect rule has a
|
||||||
// self-contradictory condition (`acl1 !acl1`).
|
// self-contradictory condition (`acl1 !acl1`).
|
||||||
|
|||||||
@@ -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