Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/license-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ jobs:
- name: Check Markdown/MDX lineage contract
run: python3 scripts/docs/catalog_lineage_docs.py --check-frontmatter

health-url-references:
name: Verify health-endpoint references resolve to real routes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- name: Check health-endpoint references
run: python3 scripts/checks/check_health_urls.py

license-text:
name: Verify LICENSE is AGPL-3.0
runs-on: ubuntu-latest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function atlasRedirect(Request $request, ?string $path = null): RedirectR
public function webApiRedirect(Request $request, ?string $path = null): RedirectResponse
{
if (! $path) {
return redirect('/api/v1/health', 301);
return redirect('/api/health', 301);
}

$segments = explode('/', trim($path, '/'));
Expand Down Expand Up @@ -140,6 +140,6 @@ public function webApiRedirect(Request $request, ?string $path = null): Redirect
}

// Unknown WebAPI paths → health check
return redirect('/api/v1/health', 301);
return redirect('/api/health', 301);
}
}
8 changes: 7 additions & 1 deletion backend/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,14 @@
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Route;

// Public health check
// Public health check. routes/api.php is mounted under the "/api" prefix, so
// this is GET /api/health (the canonical endpoint).
Route::get('/health', [HealthController::class, 'index']);
// Backward-compatible alias → GET /api/v1/health. Consumers that assume the
// /api/v1 prefix (the installer readiness probe, external uptime monitors,
// already-shipped installer binaries, legacy WebAPI redirects) get the same
// payload instead of a 404. Direct route, NOT a redirect: probes check for 200.
Route::get('/v1/health', [HealthController::class, 'index']);

// Broadcasting auth — registered under Sanctum so SPA bearer tokens work.
// Must use /api/broadcasting/auth path; Echo is configured to match.
Expand Down
13 changes: 10 additions & 3 deletions backend/tests/Feature/Api/V1/HealthCheckTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
<?php

test('health endpoint returns ok', function () {
$response = $this->getJson('/api/health');
// Both the canonical /api/health and its backward-compatible /api/v1/health alias
// must return 200 with the same shape. If the alias route is ever dropped or the
// health route regresses, this fails — and the reference guard
// (scripts/checks/check_health_urls.py) then flags any consumer still on the
// dropped path. Add a path here whenever a new health route is added in api.php.
dataset('health_paths', ['/api/health', '/api/v1/health']);

test('health endpoint returns ok', function (string $path) {
$response = $this->getJson($path);

$response->assertStatus(200)
->assertJsonPath('status', 'ok')
Expand All @@ -18,4 +25,4 @@
'darkstar',
],
]);
});
})->with('health_paths');
5 changes: 5 additions & 0 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,11 @@ else
if $DO_PHP || $DO_DB || $DO_OPENAPI; then
smoke_check "api /sanctum/csrf-cookie" "/sanctum/csrf-cookie" "204"
smoke_check "api /api/v1/nonexistent-endpoint" "/api/v1/nonexistent-endpoint" "404"
# Canonical health endpoint and its backward-compatible alias must both
# serve 200. Catches a route/prefix regression that would 404 the
# installer readiness probe and external monitors.
smoke_check "api /api/health" "/api/health" "200"
smoke_check "api /api/v1/health (alias)" "/api/v1/health" "200"
fi

if $DO_PHP || $DO_DB; then
Expand Down
4 changes: 2 additions & 2 deletions docs/compliance/compliance-remediation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ docker compose up -d

# 9. Verify everything is running
docker compose ps
curl -s https://parthenon.acumenus.net/api/v1/health
curl -s https://parthenon.acumenus.net/api/health
```

**Risk mitigation:** Do this on a weekend. Keep the backup copy on a separate physical drive until you've verified the encrypted volume works for 48+ hours.
Expand Down Expand Up @@ -609,7 +609,7 @@ If ePHI was accessed or disclosed without authorization:
3. Restore: `psql parthenon < backups/YYYY-MM-DD_HH-MM.sql`
4. Verify row counts against backup manifest JSON
5. Restart: `docker compose up -d`
6. Verify: `curl https://parthenon.acumenus.net/api/v1/health`
6. Verify: `curl https://parthenon.acumenus.net/api/health`

### Scenario B: Full Server Loss
1. Provision new Ubuntu 24.04 server
Expand Down
6 changes: 3 additions & 3 deletions docs/compliance/disaster-recovery-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ docker compose up -d <service>

# 5. Verify health
docker compose ps
# For PHP/API: curl https://parthenon.acumenus.net/api/v1/health
# For PHP/API: curl https://parthenon.acumenus.net/api/health
```

**Common container-specific notes:**
Expand Down Expand Up @@ -149,7 +149,7 @@ docker compose exec -T postgres psql -U parthenon < backups/latest.sql
docker compose up -d

# 7. Verify
curl https://parthenon.acumenus.net/api/v1/health
curl https://parthenon.acumenus.net/api/health
# Spot-check row counts against backup manifest
```

Expand Down Expand Up @@ -213,7 +213,7 @@ sudo certbot --apache -d parthenon.acumenus.net

# 12. Verify all services
docker compose ps
curl https://parthenon.acumenus.net/api/v1/health
curl https://parthenon.acumenus.net/api/health
```

### Scenario D: Ransomware
Expand Down
2 changes: 1 addition & 1 deletion docs/compliance/incident-response-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ sudo -u postgres pg_dump parthenon > /tmp/incident-db-snapshot.sql
- Run Wazuh syscheck scan: wait for next scheduled scan or trigger manually
- Run CIS SCA benchmark check
- Verify all 29 containers healthy: `docker compose ps`
- Verify API health: `curl https://parthenon.acumenus.net/api/v1/health`
- Verify API health: `curl https://parthenon.acumenus.net/api/health`
- Verify database integrity: compare row counts against backup manifest
4. **Monitor closely** for 48 hours post-recovery. Watch for:
- Recurring alerts from the same detection source
Expand Down
2 changes: 1 addition & 1 deletion docs/lineage/modules/finngen/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ docker compose exec php sh -c 'cd /var/www/html && php artisan config:clear && p
./deploy.sh --frontend

# 8. Post-deploy verification
curl -s https://parthenon.acumenus.net/api/v1/health | jq '.finngen'
curl -s https://parthenon.acumenus.net/api/health | jq '.services.darkstar'
docker compose exec php sh -c 'cd /var/www/html && php artisan finngen:smoke-test'
# Expected: both pass

Expand Down
4 changes: 2 additions & 2 deletions installer/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@


def probe(app_url: str, attempt: int) -> dict[str, Any]:
"""Probe `<app_url>/api/v1/health` and return the result.
"""Probe `<app_url>/api/health` and return the result.

Returns:
{"ready": bool, "attempt": int, "last_status": int}
last_status is 0 when the connection failed entirely.
"""
url = app_url.rstrip("/") + "/api/v1/health"
url = app_url.rstrip("/") + "/api/health"
try:
status, _body = _http_get(url)
except URLError:
Expand Down
2 changes: 1 addition & 1 deletion installer/rust-gui/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ <h2>Verifying Parthenon</h2>
<div class="verify-row" data-verify="nginx"><span class="verify-pip">○</span> Web server accepting connections</div>
<div class="verify-row" data-verify="php"><span class="verify-pip">○</span> Application runtime responding</div>
<div class="verify-row" data-verify="postgres"><span class="verify-pip">○</span> Database ready</div>
<div class="verify-row" data-verify="health"><span class="verify-pip">○</span> /api/v1/health returns 200</div>
<div class="verify-row" data-verify="health"><span class="verify-pip">○</span> /api/health returns 200</div>
<div class="verify-row" data-verify="frontend"><span class="verify-pip">○</span> Frontend assets served</div>
</div>
<div class="verify-status">
Expand Down
6 changes: 3 additions & 3 deletions installer/tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def test_probe_strips_trailing_slash_from_url():
with patch("installer.health._http_get") as mock_get:
mock_get.return_value = (200, "")
result = health.probe("http://localhost:8082/", attempt=1)
mock_get.assert_called_once_with("http://localhost:8082/api/v1/health")
mock_get.assert_called_once_with("http://localhost:8082/api/health")
assert result == {"ready": True, "attempt": 1, "last_status": 200}


Expand All @@ -42,7 +42,7 @@ def test_http_get_translates_httperror_to_status_code():
from urllib.error import HTTPError

fake_error = HTTPError(
url="http://localhost:8082/api/v1/health",
url="http://localhost:8082/api/health",
code=502,
msg="Bad Gateway",
hdrs=None, # type: ignore[arg-type]
Expand All @@ -52,7 +52,7 @@ def test_http_get_translates_httperror_to_status_code():
# Exercise _http_get directly — NOT the seam mock — to verify the real urlopen path.
with patch("installer.health.urlopen") as mock_urlopen:
mock_urlopen.side_effect = fake_error
status, body = health._http_get("http://localhost:8082/api/v1/health")
status, body = health._http_get("http://localhost:8082/api/health")

assert status == 502
assert "upstream nginx" in body
148 changes: 148 additions & 0 deletions scripts/checks/check_health_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Health-endpoint reference guard — permanent + evolving.

Prevents the "wrong health path" class of bug from recurring (a consumer probing
a backend health URL that does not exist, e.g. the `/api/v1/health` 404 that hung
the installer's readiness loop while the real route was `/api/health`).

How it stays correct as the codebase evolves: it does NOT hard-code the canonical
path. It DERIVES the set of real backend health routes from the actual route
table (`backend/routes/api.php` — the single source of truth), then scans
live-consumer surfaces for references to the main-app health path family
`/api[/vN]/health` and fails if any reference points at a path the backend does
not actually serve.

Consequences that make this self-maintaining:
* Add or rename a HealthController route in api.php -> allowed set updates,
no edit here required.
* Remove the `/v1/health` compat alias -> every straggler that
still references it is flagged, forcing consumers onto the canonical path.
* A new typo (`/api/v2/health`, `/api/healthz`, ...) in installer/infra/etc.
-> flagged immediately.

Out of scope by design: documentation under docs/ (it records historical state),
bare `/health` sidecar endpoints, and unrelated `/api/health` belonging to other
services (Grafana) — the latter happens to coincide with a canonical path and is
harmless.

Usage: python3 scripts/checks/check_health_urls.py
Exit: 0 = OK, 1 = bad references found, 2 = could not derive canonical set.
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]

# routes/api.php is mounted under the "/api" prefix by RouteServiceProvider,
# NOT "/api/v1" — which is the entire reason this class of bug exists.
API_PREFIX = "/api"
ROUTE_FILE = REPO / "backend" / "routes" / "api.php"

# The main-app health path family. Matches /api/health, /api/v1/health, ...
# Deliberately NOT /api/v1/etl/fhir/health (health is not immediately after the
# version segment) and NOT bare /health (sidecars / other services).
HEALTH_REF = re.compile(r"/api(?:/v\d+)?/health\b")

# A HealthController route line, e.g. Route::get('/v1/health', [HealthController::class, 'index']).
# The negative lookbehind excludes SystemHealthController::class.
_ROUTE_DECL = re.compile(r"(?<![A-Za-z])HealthController::class")
_ROUTE_PATH = re.compile(r"""['"](/[A-Za-z0-9_/-]*health[A-Za-z0-9_/-]*)['"]""")

# Surfaces that probe/redirect to the backend health endpoint. Globs are relative
# to the repo root. docs/ is intentionally absent (historical references allowed).
CONSUMER_GLOBS = [
"installer/**/*.py",
"installer/**/*.html",
"deploy.sh",
"scripts/**/*.sh",
"scripts/**/*.py",
"backend/app/**/*.php",
"backend/routes/*.php",
"frontend/src/**/*.ts",
"frontend/src/**/*.tsx",
"docker-compose*.yml",
"docker/**/*.yml",
"docker/**/*.conf",
"acropolis/**/*.yml",
"acropolis/**/*.yaml",
"monitoring/**/*.alloy",
"monitoring/**/*.yml",
"monitoring/**/*.yaml",
]

# Files that legitimately enumerate the canonical set itself.
SELF_EXCLUDE = {
"scripts/checks/check_health_urls.py",
"backend/routes/api.php",
"backend/tests/Feature/Api/V1/HealthCheckTest.php",
}


def canonical_health_paths() -> set[str]:
"""Derive the real backend health routes from api.php (source of truth)."""
paths: set[str] = set()
for line in ROUTE_FILE.read_text(encoding="utf-8").splitlines():
if not _ROUTE_DECL.search(line):
continue
m = _ROUTE_PATH.search(line)
if m:
paths.add(API_PREFIX + m.group(1))
return paths


def find_violations(canonical: set[str]) -> list[tuple[str, int, str]]:
violations: list[tuple[str, int, str]] = []
seen: set[str] = set()
for pattern in CONSUMER_GLOBS:
for path in REPO.glob(pattern):
if not path.is_file():
continue
rel = path.relative_to(REPO).as_posix()
if rel in SELF_EXCLUDE or rel in seen:
continue
seen.add(rel)
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
for ref in HEALTH_REF.findall(line):
if ref not in canonical:
violations.append((rel, lineno, line.strip()[:160]))
return violations


def main() -> int:
canonical = canonical_health_paths()
if not canonical:
print(
"ERROR: no HealthController routes found in backend/routes/api.php.\n"
" The route file changed shape — update this guard's parser.",
file=sys.stderr,
)
return 2

violations = find_violations(canonical)
canon = ", ".join(sorted(canonical))

if violations:
print("Health-endpoint reference guard FAILED.")
print(f"Real backend health routes (from api.php): {canon}")
print("These live-consumer references point at a health path the backend does NOT serve:")
for rel, lineno, snippet in violations:
print(f" {rel}:{lineno}: {snippet}")
print(
"\nFix: point the reference at a real route, or add the route to "
"backend/routes/api.php (and a HealthCheckTest assertion)."
)
return 1

print(f"OK: all live-consumer health references resolve to real routes ({canon}).")
return 0


if __name__ == "__main__":
raise SystemExit(main())
16 changes: 16 additions & 0 deletions scripts/githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ if [ -d templates ] && command -v uv >/dev/null 2>&1; then
fi
fi

# ── Health endpoint reference guard ─────────────────────────────────────────
# Prevents a consumer (installer probe, deploy smoke, infra healthcheck, SPA)
# from referencing a backend health URL that does not exist — the /api/v1/health
# 404 that silently hung the installer readiness loop. Derives the real routes
# from backend/routes/api.php, so it stays correct as routes evolve.
if command -v python3 > /dev/null 2>&1; then
echo "Pre-commit: Health URLs..."
if python3 scripts/checks/check_health_urls.py > /tmp/parthenon_health_guard.out 2>&1; then
echo " ✓ Health URLs"
else
cat /tmp/parthenon_health_guard.out
echo " ✗ Health URLs — fix the reference, or add the route to api.php + a HealthCheckTest assertion."
ERRORS=$((ERRORS + 1))
fi
fi

# ── Result ──────────────────────────────────────────────────────────────────
if [ "$ERRORS" -gt 0 ]; then
echo ""
Expand Down
Loading