HH-446: restore auditable quality gates from current main (#102)

* HH-446: restore auditable quality gates

* test(HH-446): restore help center smoke contract

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-22 15:45:58 +08:00
committed by GitHub
co-authored by rogee
parent f719529d66
commit 61376a57fd
7 changed files with 652 additions and 40 deletions
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Validate Go test coverage, execution evidence, and elapsed time."""
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
def coverage(path):
files = defaultdict(lambda: [0, 0])
total = [0, 0]
for line in Path(path).read_text(encoding="utf-8").splitlines():
if not line or line.startswith("mode:"):
continue
fields = line.split()
if len(fields) != 3:
raise ValueError(f"invalid coverage line: {line}")
file_name = fields[0].split(":", 1)[0]
statements, count = int(fields[1]), int(fields[2])
total[0] += statements
total[1] += statements if count else 0
files[file_name][0] += statements
files[file_name][1] += statements if count else 0
if total[0] == 0:
raise ValueError("coverage profile has no statements")
return total, files
def test_evidence(path):
packages = defaultdict(lambda: {"pass": 0, "fail": 0, "skip": 0})
totals = {"pass": 0, "fail": 0, "skip": 0}
for line in Path(path).read_text(encoding="utf-8").splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
action = event.get("Action")
package = event.get("Package", "")
if not event.get("Test") or action not in totals:
continue
packages[package][action] += 1
totals[action] += 1
return totals, packages
def package_matches(name, spec):
recursive = spec.endswith("/...")
package = spec.removesuffix("/...")
return name == package or recursive and name.startswith(package + "/")
def run(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument("--coverage", required=True)
parser.add_argument("--test-json", required=True)
parser.add_argument("--duration", required=True)
parser.add_argument("--report", required=True)
parser.add_argument("--min-coverage", type=float, default=65.0)
parser.add_argument("--max-duration", type=float, required=True)
parser.add_argument("--test-exit-code", type=int, default=0)
parser.add_argument("--critical-package", action="append", default=[])
args = parser.parse_args(argv)
failures = []
try:
total, files = coverage(args.coverage)
total_tests, packages = test_evidence(args.test_json)
elapsed = float(Path(args.duration).read_text(encoding="utf-8").strip())
except (OSError, ValueError) as error:
failures.append(str(error))
total, files = [0, 0], {}
total_tests, packages, elapsed = {"pass": 0, "fail": 0, "skip": 0}, {}, None
percent = total[1] / total[0] * 100 if total[0] else 0.0
if percent < args.min_coverage:
failures.append(f"overall coverage {percent:.2f}% is below {args.min_coverage:.2f}%")
if args.test_exit_code:
failures.append(f"go test exited with {args.test_exit_code}")
if total_tests["fail"]:
failures.append(f"test evidence contains {total_tests['fail']} failed test events")
if elapsed is not None and elapsed > args.max_duration:
failures.append(f"test duration {elapsed:.2f}s exceeds {args.max_duration:.2f}s")
if sum(total_tests.values()) == 0:
failures.append("test evidence contains no test results")
critical, critical_coverage = [], []
for spec in args.critical_package:
matched = {
name: values for name, values in packages.items() if package_matches(name, spec)
}
item = {
"package": spec,
"matched": sorted(matched),
"passed": sum(values["pass"] for values in matched.values()),
"failed": sum(values["fail"] for values in matched.values()),
"skipped": sum(values["skip"] for values in matched.values()),
}
critical.append(item)
if not matched:
failures.append(f"critical suite {spec} produced no package evidence")
elif item["passed"] == 0:
failures.append(f"critical suite {spec} was entirely skipped")
matching = [
values
for name, values in files.items()
if package_matches(name.rsplit("/", 1)[0], spec)
]
statements = sum(values[0] for values in matching)
covered = sum(values[1] for values in matching)
coverage_item = {
"package": spec,
"covered_statements": covered,
"total_statements": statements,
"percent": round(covered / statements * 100, 2) if statements else 0.0,
}
critical_coverage.append(coverage_item)
if not statements:
failures.append(f"critical coverage {spec} has no statements")
elif coverage_item["percent"] < args.min_coverage:
failures.append(
f"critical coverage {spec} {coverage_item['percent']:.2f}% "
f"is below {args.min_coverage:.2f}%"
)
report = {
"schema_version": 1,
"status": "failed" if failures else "passed",
"thresholds": {
"min_coverage_percent": args.min_coverage,
"max_duration_seconds": args.max_duration,
},
"coverage": {
"covered_statements": total[1],
"total_statements": total[0],
"percent": round(percent, 2),
"critical": critical_coverage,
"files": {
name: {
"covered_statements": values[1],
"total_statements": values[0],
"percent": round(values[1] / values[0] * 100, 2) if values[0] else 0.0,
}
for name, values in sorted(files.items())
},
},
"tests": {**total_tests, "critical": critical},
"duration_seconds": elapsed,
"test_exit_code": args.test_exit_code,
"failures": failures,
}
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(
json.dumps(
{
"status": report["status"],
"coverage_percent": report["coverage"]["percent"],
"tests": report["tests"],
"duration_seconds": elapsed,
"failures": failures,
},
sort_keys=True,
)
)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(run())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
QUALITY_DIR="${GOCHAT_QUALITY_DIR:-$ROOT/.tmp/quality}"
mkdir -p "$QUALITY_DIR"
RACE="${GOCHAT_RACE:-true}"
COLD_CACHE="${GOCHAT_COLD_CACHE:-true}"
MIN_COVERAGE="${GOCHAT_MIN_COVERAGE:-65}"
MAX_DURATION="${GOCHAT_MAX_DURATION_SECONDS:-300}"
COVERAGE_FILE="${GOCHAT_COVERAGE_FILE:-$QUALITY_DIR/coverage.out}"
TEST_JSON="${GOCHAT_TEST_JSON:-$QUALITY_DIR/test.jsonl}"
DURATION_FILE="${GOCHAT_DURATION_FILE:-$QUALITY_DIR/duration.seconds}"
REPORT_FILE="${GOCHAT_QUALITY_REPORT:-$QUALITY_DIR/quality-gate.json}"
packages=(./internal/... ./pkg/... ./cmd/...)
if [[ -n "${GOCHAT_TEST_PACKAGES:-}" ]]; then
read -r -a packages <<< "$GOCHAT_TEST_PACKAGES"
fi
if [[ "$COLD_CACHE" == "true" ]]; then
go clean -testcache
fi
race_args=()
if [[ "$RACE" == "true" ]]; then
race_args=(-race)
fi
start="$(date +%s.%N)"
set +e
go test -json -count=1 "${race_args[@]}" -covermode=atomic \
-coverprofile="$COVERAGE_FILE" -timeout "${GOCHAT_TEST_TIMEOUT:-300s}" \
"${packages[@]}" | tee "$TEST_JSON" >/dev/null
test_status=${PIPESTATUS[0]}
set -e
end="$(date +%s.%N)"
awk -v start="$start" -v end="$end" 'BEGIN { printf "%.3f\n", end-start }' > "$DURATION_FILE"
set +e
python3 "$ROOT/scripts/ci_quality_gate.py" \
--coverage "$COVERAGE_FILE" \
--test-json "$TEST_JSON" \
--duration "$DURATION_FILE" \
--report "$REPORT_FILE" \
--min-coverage "$MIN_COVERAGE" \
--max-duration "$MAX_DURATION" \
--test-exit-code "$test_status" \
--critical-package github.com/gochat/gochat/internal/ws \
--critical-package github.com/gochat/gochat/internal/handler/ws \
--critical-package github.com/gochat/gochat/internal/worker \
--critical-package github.com/gochat/gochat/internal/channel/...
gate_status=$?
set -e
[[ "$test_status" -eq 0 ]] || exit "$test_status"
exit "$gate_status"
@@ -1,6 +1,13 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import {
closeSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -20,6 +27,39 @@ const frontendBaseURL = `http://${shellHost}:${shellPort}`;
const apiBaseURL = `http://${apiHost}:${apiPort}`;
const enterpriseMode = process.argv.includes('--enterprise');
function isSuccessfulRequest(request, substring) {
return request.url.includes(substring) && request.status >= 200 && request.status < 400;
}
function isFailedAPIRequest(request) {
const isBackendAPI = request.url.includes(apiBaseURL);
const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (
request.url.includes('/api/') ||
request.url.includes('/enterprise/') ||
request.url.includes('/public/') ||
request.url.includes('/auth/') ||
request.url.includes('/rails/')
);
if (!isBackendAPI && !isShellProxiedAPI || request.type === 'Preflight') return false;
const isNavigationAbort = request.type === 'Document' &&
['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText);
return request.status >= 400 || request.status === 0 && !isNavigationAbort;
}
if (process.argv.includes('--self-test')) {
const apiURL = `${apiBaseURL}/api/v1/profile`;
const checks = [
isSuccessfulRequest({ url: apiURL, status: 200 }, '/api/v1/profile'),
!isSuccessfulRequest({ url: apiURL, status: 500 }, '/api/v1/profile'),
isFailedAPIRequest({ url: apiURL, status: 0, type: 'Fetch', errorText: 'net::ERR_FAILED' }),
!isFailedAPIRequest({ url: apiURL, status: 0, type: 'Document', errorText: 'net::ERR_ABORTED' }),
!isFailedAPIRequest({ url: `${frontendBaseURL}/favicon.ico`, status: 404, type: 'Image' }),
];
if (checks.some(check => !check)) throw new Error('browser smoke failure contract self-test failed');
console.log('browser smoke failure contract self-test: ok');
process.exit(0);
}
mkdirSync(logDir, { recursive: true });
const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8'));
@@ -38,7 +78,7 @@ const report = {
function smokeHTML(entrypoint, route) {
const config = {
apiHost: apiBaseURL,
apiHost: '',
hostURL: frontendBaseURL,
helpCenterURL: '',
allowedLoginMethods: ['email'],
@@ -78,7 +118,7 @@ function smokeHTML(entrypoint, route) {
window.errorLoggingConfig = '';
window.analyticsConfig = { token: '' };
</script>
<script type="module" src="/vite-dev/entrypoints/${entrypoint}.js"></script>
<script type="module" src="${viteBaseURL}/app/javascript/entrypoints/${entrypoint}.js"></script>
</head>
<body class="text-slate-600"><div id="app"></div></body>
</html>`;
@@ -114,7 +154,7 @@ function widgetSmokeHTML(route) {
window.browserConfig = { browser_name: 'chrome' };
window.errorLoggingConfig = '';
</script>
<script type="module" src="/vite-dev/entrypoints/widget.js"></script>
<script type="module" src="${viteBaseURL}/app/javascript/entrypoints/widget.js"></script>
</head>
<body class="text-slate-600"><div id="app"></div></body>
</html>`;
@@ -393,6 +433,7 @@ class CDPPage {
report.requests.push({
url: this.requestURLs.get(params.requestId) || params.requestId,
status: 0,
type: params.type,
errorText: params.errorText,
});
});
@@ -456,14 +497,14 @@ class CDPPage {
);
}
async waitForCapturedRequest(substring, label, timeout = 30000) {
return this.waitForCapturedRequestAfter(substring, 0, label, timeout);
async waitForSuccessfulRequest(substring, label, timeout = 30000) {
return this.waitForSuccessfulRequestAfter(substring, 0, label, timeout);
}
async waitForCapturedRequestAfter(substring, requestIndex, label, timeout = 30000) {
async waitForSuccessfulRequestAfter(substring, requestIndex, label, timeout = 30000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (report.requests.slice(requestIndex).some(request => request.url.includes(substring))) {
if (report.requests.slice(requestIndex).some(request => isSuccessfulRequest(request, substring))) {
report.checks.push({ label, status: 'passed' });
return;
}
@@ -473,22 +514,7 @@ class CDPPage {
}
assertNoFailedAPIRequests(requestIndex = 0) {
const failures = report.requests.slice(requestIndex).filter(request => {
const isBackendAPI = request.url.includes(apiBaseURL);
const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (
request.url.includes('/api/') ||
request.url.includes('/enterprise/') ||
request.url.includes('/public/') ||
request.url.includes('/auth/') ||
request.url.includes('/rails/')
);
if (!isBackendAPI && !isShellProxiedAPI) return false;
if (request.type === 'Preflight') return false;
if (request.status === 0 && ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText)) {
return false;
}
return request.status >= 400 || request.status === 0;
});
const failures = report.requests.slice(requestIndex).filter(isFailedAPIRequest);
if (failures.length > 0) {
throw new Error(`Frontend API requests failed: ${failures.map(request => `${request.status} ${request.url}`).join('; ')}`);
}
@@ -502,17 +528,33 @@ class CDPPage {
}
async function launchChrome() {
if (typeof WebSocket !== 'function') {
throw new Error('Node.js WebSocket API is unavailable; use --experimental-websocket');
}
const userDataDir = mkdtempSync(path.join(tmpdir(), 'gochat-chrome-'));
const chrome = spawn(chromePath, [
const args = [
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--disable-dev-shm-usage',
'--enable-logging=stderr',
'--remote-debugging-port=0',
`--user-data-dir=${userDataDir}`,
'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
];
if (process.env.CI) args.push('--no-sandbox');
args.push('about:blank');
const chromeLogPath = path.join(logDir, 'chrome.log');
const chromeLog = openSync(chromeLogPath, 'w');
const chrome = spawn(chromePath, args, { stdio: ['ignore', 'ignore', chromeLog] });
closeSync(chromeLog);
let chromeFailure = '';
chrome.once('error', error => {
chromeFailure = `failed to launch Chrome: ${error.message}`;
});
chrome.once('exit', (code, signal) => {
chromeFailure = `Chrome exited before DevTools was ready (code=${code}, signal=${signal})`;
});
const portFile = path.join(userDataDir, 'DevToolsActivePort');
for (let i = 0; i < 80; i += 1) {
try {
@@ -527,11 +569,12 @@ async function launchChrome() {
await page.init();
return page;
} catch {
if (chromeFailure) break;
await new Promise(resolve => setTimeout(resolve, 250));
}
}
chrome.kill('SIGTERM');
throw new Error('Chrome DevTools did not become ready');
throw new Error(`${chromeFailure || 'Chrome DevTools did not become ready'}; see ${chromeLogPath}`);
}
async function main() {
@@ -547,17 +590,17 @@ async function main() {
await page.navigate(smokePages.dashboard);
await page.waitForAppMounted('dashboard app mounted');
await page.waitForCapturedRequest('/auth/validate_token', 'dashboard validates auth token');
await page.waitForCapturedRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
await page.waitForSuccessfulRequest('/auth/validate_token', 'dashboard validates auth token');
await page.waitForSuccessfulRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
page.assertNoFailedAPIRequests();
const widgetRequestIndex = report.requests.length;
await page.navigate(smokePages.widget);
await page.waitForAppMounted('widget app mounted');
await page.waitForCapturedRequest('/api/v1/widget/messages', 'widget requests messages');
await page.waitForCapturedRequest('/api/v1/widget/inbox_members', 'widget requests inbox members');
await page.waitForSuccessfulRequest('/api/v1/widget/messages', 'widget requests messages');
await page.waitForSuccessfulRequest('/api/v1/widget/inbox_members', 'widget requests inbox members');
await page.eval(`fetch('/api/v1/widget/campaigns?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}').then(response => response.ok)`);
await page.waitForCapturedRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works');
await page.waitForSuccessfulRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works');
page.assertNoFailedAPIRequests(widgetRequestIndex);
if (enterpriseMode) {
@@ -566,7 +609,7 @@ async function main() {
await page.navigate(enterprisePage.url);
await page.waitForAppMounted(`${enterprisePage.label} app mounted`);
for (const request of enterprisePage.requests) {
await page.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`);
await page.waitForSuccessfulRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`);
}
page.assertNoFailedAPIRequests(requestIndex);
}
@@ -586,7 +629,7 @@ async function main() {
})(),
body: JSON.stringify({ message: 'B12 enterprise browser copilot smoke', assistant_id: ${Number(seed.captain_assistant_id)}, conversation_id: ${Number(seed.conversation_id)} })
}).then(response => response.ok)`);
await page.waitForCapturedRequestAfter('/captain/copilot_threads', copilotRequestIndex, 'browser context requests Copilot threads');
await page.waitForSuccessfulRequestAfter('/captain/copilot_threads', copilotRequestIndex, 'browser context requests Copilot threads');
page.assertNoFailedAPIRequests(copilotRequestIndex);
}
report.finished_at = new Date().toISOString();
+3 -3
View File
@@ -478,7 +478,7 @@ SEED
run_browser_smoke() {
run_api_smoke
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
GOCHAT_ROOT="$ROOT" \
CHATWOOT_DIR="$CHATWOOT_DIR" \
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
@@ -486,7 +486,7 @@ run_browser_smoke() {
GOCHAT_SMOKE_API_PORT="$API_PORT" \
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
node "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
node --experimental-websocket "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
BOOT_BACKEND_RESULT="Passed /health during browser smoke" \
BOOT_FRONTEND_RESULT="Passed Vite readiness during browser smoke" \
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
@@ -793,7 +793,7 @@ wait_url "http://$API_HOST:$API_PORT/health" "GoChat"
echo "starting reused Chatwoot frontend..."
(cd "$CHATWOOT_DIR" && "${frontend_cmd[@]}") >"$LOG_DIR/chatwoot-vite.log" 2>&1 &
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
BOOT_BACKEND_RESULT="Passed boot readiness" \
BOOT_FRONTEND_RESULT="Passed boot readiness" \
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Failure-injection checks for CI quality gates."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
GATE = ROOT / "ci_quality_gate.py"
HEALTH = ROOT / "validate_health_json.py"
def run(command):
return subprocess.run(command, capture_output=True, text=True)
def write_fixtures(
directory,
coverage_lines=(
"github.com/gochat/gochat/internal/ws/file.go:1.1,1.2 2 1\n"
"github.com/gochat/gochat/internal/ws/file.go:2.1,2.2 2 1\n"
),
test_lines=None,
duration="1",
):
(directory / "coverage.out").write_text("mode: atomic\n" + coverage_lines, encoding="utf-8")
events = test_lines or [
{"Action": "pass", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
]
(directory / "test.jsonl").write_text(
"\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8"
)
(directory / "duration").write_text(duration, encoding="utf-8")
def gate(directory, budget=5, exit_code=0):
return run(
[
sys.executable,
str(GATE),
"--coverage",
str(directory / "coverage.out"),
"--test-json",
str(directory / "test.jsonl"),
"--duration",
str(directory / "duration"),
"--report",
str(directory / "report.json"),
"--min-coverage",
"65",
"--max-duration",
str(budget),
"--test-exit-code",
str(exit_code),
"--critical-package",
"github.com/gochat/gochat/internal/ws",
]
)
def main():
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
write_fixtures(directory)
assert gate(directory).returncode == 0
write_fixtures(
directory,
"github.com/gochat/gochat/internal/ws/file.go:1.1,1.2 2 1\n"
"github.com/gochat/gochat/internal/ws/file.go:2.1,2.2 2 0\n",
)
assert gate(directory).returncode != 0, "coverage regression must fail"
write_fixtures(
directory,
test_lines=[
{"Action": "skip", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
],
)
assert gate(directory).returncode != 0, "all-skipped critical suite must fail"
write_fixtures(
directory,
"github.com/gochat/gochat/internal/wsevent/file.go:1.1,1.2 2 1\n",
[{"Action": "pass", "Package": "github.com/gochat/gochat/internal/wsevent", "Test": "TestBridge"}],
)
assert gate(directory).returncode != 0, "sibling package must not satisfy the ws gate"
write_fixtures(directory, duration="6")
assert gate(directory).returncode != 0, "duration budget must fail"
write_fixtures(
directory,
test_lines=[
{"Action": "fail", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
],
)
assert gate(directory).returncode != 0, "failed test evidence must fail"
write_fixtures(directory)
assert gate(directory, exit_code=2).returncode != 0, "non-zero go test status must fail"
health = directory / "health.json"
health.write_text(
json.dumps(
{
"status": "healthy",
"timestamp": "2026-08-22T00:00:00Z",
"uptime": "1s",
"checks": {"database": "healthy"},
}
),
encoding="utf-8",
)
assert run([sys.executable, str(HEALTH), str(health)]).returncode == 0
health.write_text("{broken", encoding="utf-8")
assert run([sys.executable, str(HEALTH), str(health)]).returncode != 0, "invalid health JSON must fail"
health.write_text(json.dumps({"status": "unhealthy"}), encoding="utf-8")
assert run([sys.executable, str(HEALTH), str(health)]).returncode != 0, "unhealthy contract must fail"
print("quality gate failure-injection checks: ok")
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Validate the stable JSON contract returned by GET /health."""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
def validate(value):
if not isinstance(value, dict):
return "health response must be a JSON object"
missing = sorted({"status", "timestamp", "uptime", "checks"} - value.keys())
if missing:
return f"health response missing fields: {', '.join(missing)}"
if value["status"] != "healthy":
return f"unexpected health status: {value['status']}"
try:
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
except (AttributeError, ValueError):
return "timestamp must be RFC3339"
if not isinstance(value["uptime"], str) or not value["uptime"]:
return "uptime must be a non-empty string"
if not isinstance(value["checks"], dict) or value["checks"].get("database") != "healthy":
return "database health check must be healthy"
return None
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument("path", nargs="?", help="JSON file; stdin when omitted")
args = parser.parse_args(argv)
try:
raw = Path(args.path).read_text(encoding="utf-8") if args.path else sys.stdin.read()
error = validate(json.loads(raw))
except (OSError, json.JSONDecodeError) as exc:
error = f"invalid health JSON: {exc}"
if error:
print(error, file=sys.stderr)
return 1
print("health JSON contract: ok")
return 0
if __name__ == "__main__":
sys.exit(main())