This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { buildSoakSummary } from './wallboard-soak-analysis.mjs';
|
||||
|
||||
const directory = path.resolve(process.argv[2] ?? 'artifacts/evidence/M10-14/raw');
|
||||
|
||||
async function readJSON(name) {
|
||||
return JSON.parse(await readFile(path.join(directory, name), 'utf8'));
|
||||
}
|
||||
|
||||
async function readNDJSON(name, required = true) {
|
||||
try {
|
||||
const content = await readFile(path.join(directory, name), 'utf8');
|
||||
return content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
} catch (error) {
|
||||
if (!required && error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const [metadata, recorded, samples, reconnects, events] = await Promise.all([
|
||||
readJSON('metadata.json'),
|
||||
readJSON('result.json'),
|
||||
readNDJSON('samples.ndjson'),
|
||||
readNDJSON('reconnects.ndjson', false),
|
||||
readNDJSON('events.ndjson'),
|
||||
]);
|
||||
|
||||
const completedAt = new Date(recorded.completedAt);
|
||||
const completedMs = completedAt.getTime();
|
||||
if (!Number.isFinite(completedMs)) throw new Error('result.json has no valid completedAt');
|
||||
const relevantEvents = events.filter((event) => Date.parse(event.timestamp) <= completedMs);
|
||||
const openedSockets = relevantEvents.filter((event) => event.type === 'websocket-open').length;
|
||||
const closedSockets = relevantEvents.filter((event) => event.type === 'websocket-close').length;
|
||||
const recomputed = buildSoakSummary({
|
||||
samples,
|
||||
reconnects,
|
||||
startedAt: new Date(metadata.startedAt),
|
||||
completedAt,
|
||||
durationHours: metadata.durationHours,
|
||||
sampleSeconds: metadata.sampleSeconds,
|
||||
counters: {
|
||||
maxActiveSockets: Math.max(0, ...relevantEvents.map((event) => Number(event.activeSockets) || 0)),
|
||||
openedSockets,
|
||||
closedSockets,
|
||||
websocketErrors: relevantEvents.filter((event) => event.type === 'websocket-error').length,
|
||||
pageErrors: relevantEvents.filter((event) => event.type === 'page-error').length,
|
||||
apiFailures: relevantEvents.filter((event) => event.type === 'api-failure').length,
|
||||
},
|
||||
requireWallboardTitle: true,
|
||||
});
|
||||
|
||||
const comparison = {
|
||||
statusMatches: recomputed.status === recorded.status,
|
||||
sampleCountMatches: recomputed.sampleCount === recorded.sampleCount,
|
||||
reconnectCountMatches: recomputed.reconnectCount === recorded.reconnectCount,
|
||||
criticalMetricsMatch: [
|
||||
'expectedSampleCount', 'minimumSampleCount', 'maxSampleGapSeconds', 'maxAllowedSampleGapSeconds',
|
||||
'reconnectFailures', 'retainedGrowthBytes', 'heapSlopeBytesPerHour', 'fpsP05',
|
||||
'frameP95WorstMs', 'longestTaskMs', 'maxActiveSockets', 'openedSockets',
|
||||
'closedSockets', 'websocketErrors', 'socketChurnBudget', 'pageErrors',
|
||||
'apiFailures', 'maxDOMNodes', 'horizontalOverflowSamples', 'invalidWallboardSamples',
|
||||
].every((field) => Object.is(recomputed[field], recorded[field])),
|
||||
};
|
||||
|
||||
const verification = {
|
||||
directory,
|
||||
recordedStatus: recorded.status,
|
||||
recomputedStatus: recomputed.status,
|
||||
comparison,
|
||||
recomputed,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(verification, null, 2)}\n`);
|
||||
if (!Object.values(comparison).every(Boolean) || recomputed.status !== 'pass') process.exitCode = 1;
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when the documented Pulse API and registered HTTP routes drift apart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "specs" / "api-routes.json"
|
||||
CONTRACT = ROOT / "docs" / "architecture" / "API_CONTRACT.md"
|
||||
ROUTERS = (ROOT / "cmd" / "api" / "main.go", ROOT / "internal" / "service" / "health.go")
|
||||
|
||||
TABLE_ROUTE = re.compile(r"^\| `(?P<method>GET|POST|PUT|PATCH|DELETE) (?P<path>/[^`]+)` \|", re.MULTILINE)
|
||||
REGISTERED = re.compile(r"\.Handle(?:Func)?\(\s*\"(?P<path>/[^\"]+)\"")
|
||||
|
||||
|
||||
def fail(messages: list[str]) -> int:
|
||||
for message in messages:
|
||||
print(f"API CONTRACT: FAIL: {message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
document = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
routes = document.get("routes", [])
|
||||
errors: list[str] = []
|
||||
|
||||
keys: list[tuple[str, str]] = []
|
||||
covered_registrations: set[str] = set()
|
||||
for index, route in enumerate(routes):
|
||||
missing = {"method", "path", "registration", "access", "implementation"} - route.keys()
|
||||
if missing:
|
||||
errors.append(f"route {index} misses fields: {', '.join(sorted(missing))}")
|
||||
continue
|
||||
key = (route["method"], route["path"])
|
||||
if key in keys:
|
||||
errors.append(f"duplicate manifest route: {key[0]} {key[1]}")
|
||||
keys.append(key)
|
||||
covered_registrations.add(route["registration"])
|
||||
implementation = ROOT / route["implementation"]
|
||||
if not implementation.is_file():
|
||||
errors.append(f"implementation does not exist for {key[0]} {key[1]}: {implementation.relative_to(ROOT)}")
|
||||
|
||||
markdown = CONTRACT.read_text(encoding="utf-8")
|
||||
documented = [(match.group("method"), match.group("path")) for match in TABLE_ROUTE.finditer(markdown)]
|
||||
manifest_set = set(keys)
|
||||
documented_set = set(documented)
|
||||
for method, path in sorted(manifest_set - documented_set):
|
||||
errors.append(f"manifest route is not documented: {method} {path}")
|
||||
for method, path in sorted(documented_set - manifest_set):
|
||||
errors.append(f"documented route is not in manifest: {method} {path}")
|
||||
if len(documented) != len(documented_set):
|
||||
errors.append("the route table contains duplicate method/path rows")
|
||||
|
||||
registered: set[str] = set()
|
||||
for source in ROUTERS:
|
||||
registered.update(match.group("path") for match in REGISTERED.finditer(source.read_text(encoding="utf-8")))
|
||||
for path in sorted(registered - covered_registrations):
|
||||
errors.append(f"registered router path is not represented in the manifest: {path}")
|
||||
for path in sorted(covered_registrations - registered):
|
||||
errors.append(f"manifest registration does not exist in the router: {path}")
|
||||
|
||||
if errors:
|
||||
return fail(errors)
|
||||
print(
|
||||
"API CONTRACT: PASS "
|
||||
f"({len(manifest_set)} method/path contracts, {len(registered)} router registrations, no drift)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Fail on high-confidence committed secret material or private keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SKIP_PARTS = {".git", "node_modules", "dist", "bin", "artifacts"}
|
||||
PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
|
||||
TOKEN_MARKERS = re.compile(r"(?:ghp_|github_pat_|sk-[A-Za-z0-9]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
files = subprocess.check_output(["git", "ls-files", "-z"], cwd=root).decode().split("\0")
|
||||
findings: list[str] = []
|
||||
for name in files:
|
||||
if not name:
|
||||
continue
|
||||
path = root / name
|
||||
if any(part in SKIP_PARTS for part in path.parts) or path.name in {".env.example", "check_secrets.py"} or path.name.endswith("_test.go"):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
if PRIVATE_KEY.search(content):
|
||||
findings.append(f"private key marker: {name}")
|
||||
if TOKEN_MARKERS.search(content):
|
||||
findings.append(f"token marker: {name}")
|
||||
if findings:
|
||||
print("SECRET CHECK: FAIL")
|
||||
print("\n".join(findings))
|
||||
return 1
|
||||
print("SECRET CHECK: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a first-party Go package is implemented but unreachable from a binary.
|
||||
|
||||
The audit that motivated this tool found ~2,600 lines of fully implemented,
|
||||
well-tested Go code with zero non-test importers outside its own package —
|
||||
`cmd/worker` and `cmd/agent` never called it, so it shipped as dead weight
|
||||
behind a "done" task. Acceptance checks had only ever exercised each
|
||||
package's internal behaviour, never whether `go build ./cmd/...` would ever
|
||||
pull it in.
|
||||
|
||||
This tool computes, using pure-Python source parsing (no `go list`, since the
|
||||
Go toolchain may be unavailable in CI), the transitive set of first-party
|
||||
packages reachable from the real entry points (`cmd/api`, `cmd/worker`,
|
||||
`cmd/agent`, `cmd/migrate`) through non-test imports, and fails listing every
|
||||
`internal/*` package that is implemented but unreachable and not explicitly
|
||||
allowlisted with a reason and a tracking task id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_ALLOWLIST = ROOT / "tools" / "wiring_allowlist.json"
|
||||
|
||||
GO_ROOTS = ("cmd", "internal")
|
||||
SKIP_PARTS = {".git", "node_modules", "vendor"}
|
||||
|
||||
MODULE_RE = re.compile(r"(?m)^module\s+(\S+)")
|
||||
PACKAGE_RE = re.compile(r"(?m)^package\s+\w+\b")
|
||||
PACKAGE_MAIN_RE = re.compile(r"(?m)^package\s+main\b")
|
||||
IGNORE_TAG_RE = re.compile(
|
||||
r"(?m)^//go:build\b.*\bignore\b|^//\s*\+build\b.*\bignore\b"
|
||||
)
|
||||
IMPORT_BLOCK_RE = re.compile(r"import\s*\(\s*(.*?)\n\)", re.S)
|
||||
IMPORT_SINGLE_RE = re.compile(
|
||||
r'(?m)^\s*import\s+(?:(?:[A-Za-z_][A-Za-z0-9_]*|_|\.)\s+)?"([^"]+)"'
|
||||
)
|
||||
IMPORT_LINE_RE = re.compile(
|
||||
r'^\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*|_|\.)\s+)?"([^"]+)"'
|
||||
)
|
||||
|
||||
|
||||
class AllowlistError(Exception):
|
||||
"""Raised when the wiring allowlist file is malformed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiringReport:
|
||||
module: str
|
||||
entry_points: list[str]
|
||||
implemented_internal: set[str]
|
||||
reachable: set[str]
|
||||
unreachable: set[str]
|
||||
allowlist_entries: list[dict[str, Any]]
|
||||
allowlisted_active: dict[str, dict[str, Any]]
|
||||
stale_allowlist: dict[str, dict[str, Any]]
|
||||
unknown_allowlist: dict[str, dict[str, Any]]
|
||||
failing: set[str] = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.failing = self.unreachable - set(self.allowlisted_active)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.failing and not self.stale_allowlist and not self.unknown_allowlist
|
||||
|
||||
|
||||
def read_module_path(root: Path) -> str:
|
||||
text = (root / "go.mod").read_text(encoding="utf-8")
|
||||
match = MODULE_RE.search(text)
|
||||
if not match:
|
||||
raise AllowlistError("go.mod has no module declaration")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def discover_go_files(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for go_root in GO_ROOTS:
|
||||
base = root / go_root
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in base.rglob("*.go"):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def header_text(text: str) -> str:
|
||||
"""Return the text preceding the package clause (build tags, doc comments)."""
|
||||
match = PACKAGE_RE.search(text)
|
||||
return text[: match.start()] if match else text
|
||||
|
||||
|
||||
def has_ignore_build_tag(text: str) -> bool:
|
||||
return bool(IGNORE_TAG_RE.search(header_text(text)))
|
||||
|
||||
|
||||
def parse_imports(text: str) -> set[str]:
|
||||
"""Extract first-party-or-not import paths from grouped and single import forms.
|
||||
|
||||
Handles grouped `import (...)` blocks, single `import "x"` statements,
|
||||
aliases (`alias "x"`), blank imports (`_ "x"`), and dot imports (`. "x"`).
|
||||
Blank and dot imports are still real reachability edges: they pull the
|
||||
package into the binary and run its `init()`.
|
||||
"""
|
||||
imports: set[str] = set()
|
||||
for block in IMPORT_BLOCK_RE.finditer(text):
|
||||
for line in block.group(1).splitlines():
|
||||
match = IMPORT_LINE_RE.match(line)
|
||||
if match:
|
||||
imports.add(match.group(1))
|
||||
for match in IMPORT_SINGLE_RE.finditer(text):
|
||||
imports.add(match.group(1))
|
||||
return imports
|
||||
|
||||
|
||||
def classify_files(
|
||||
files: list[Path],
|
||||
) -> tuple[dict[str, list[Path]], dict[str, list[Path]]]:
|
||||
"""Split files by package directory into (non-test, test) buckets.
|
||||
|
||||
Files carrying a `//go:build ignore` (or legacy `// +build ignore`) tag
|
||||
are dropped entirely: the Go toolchain never compiles them, so they must
|
||||
not count as either "implemented" or as import edges.
|
||||
"""
|
||||
prod_by_dir: dict[str, list[Path]] = {}
|
||||
test_by_dir: dict[str, list[Path]] = {}
|
||||
for path in files:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if has_ignore_build_tag(text):
|
||||
continue
|
||||
directory = path.parent
|
||||
key = directory.as_posix()
|
||||
if path.name.endswith("_test.go"):
|
||||
test_by_dir.setdefault(key, []).append(path)
|
||||
else:
|
||||
prod_by_dir.setdefault(key, []).append(path)
|
||||
return prod_by_dir, test_by_dir
|
||||
|
||||
|
||||
def relativize(prod_by_dir: dict[str, list[Path]], root: Path) -> dict[str, list[Path]]:
|
||||
result: dict[str, list[Path]] = {}
|
||||
for key, paths in prod_by_dir.items():
|
||||
rel = Path(key).resolve().relative_to(root.resolve()).as_posix()
|
||||
result[rel] = paths
|
||||
return result
|
||||
|
||||
|
||||
def build_graph(prod_by_dir: dict[str, list[Path]], module: str) -> dict[str, set[str]]:
|
||||
prefix = module + "/"
|
||||
graph: dict[str, set[str]] = {}
|
||||
for directory, paths in prod_by_dir.items():
|
||||
edges: set[str] = set()
|
||||
for path in paths:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for imp in parse_imports(text):
|
||||
if not imp.startswith(prefix):
|
||||
continue
|
||||
target = imp[len(prefix):]
|
||||
if target in prod_by_dir:
|
||||
edges.add(target)
|
||||
graph[directory] = edges
|
||||
return graph
|
||||
|
||||
|
||||
def find_entry_points(prod_by_dir: dict[str, list[Path]]) -> set[str]:
|
||||
entries: set[str] = set()
|
||||
for directory, paths in prod_by_dir.items():
|
||||
if not directory.startswith("cmd/"):
|
||||
continue
|
||||
for path in paths:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if PACKAGE_MAIN_RE.search(text):
|
||||
entries.add(directory)
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def reachable_from(graph: dict[str, set[str]], roots: set[str]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
for target in graph.get(current, ()):
|
||||
if target not in seen:
|
||||
stack.append(target)
|
||||
return seen
|
||||
|
||||
|
||||
def load_allowlist(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise AllowlistError(f"{path}: invalid JSON ({error})") from error
|
||||
|
||||
if isinstance(data, dict):
|
||||
entries = data.get("entries")
|
||||
else:
|
||||
entries = data
|
||||
if not isinstance(entries, list):
|
||||
raise AllowlistError(f"{path}: expected an 'entries' list")
|
||||
|
||||
validated: list[dict[str, Any]] = []
|
||||
seen_packages: set[str] = set()
|
||||
for index, raw in enumerate(entries):
|
||||
if not isinstance(raw, dict):
|
||||
raise AllowlistError(f"{path}: entry {index} is not an object")
|
||||
package = raw.get("package")
|
||||
reason = raw.get("reason")
|
||||
task = raw.get("task")
|
||||
if not isinstance(package, str) or not package.strip():
|
||||
raise AllowlistError(f"{path}: entry {index} is missing a non-empty 'package'")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise AllowlistError(
|
||||
f"{path}: allowlist entry '{package}' is missing a required 'reason'"
|
||||
)
|
||||
if not isinstance(task, str) or not task.strip():
|
||||
raise AllowlistError(
|
||||
f"{path}: allowlist entry '{package}' is missing a required tracking 'task'"
|
||||
)
|
||||
if package in seen_packages:
|
||||
raise AllowlistError(f"{path}: duplicate allowlist entry for '{package}'")
|
||||
seen_packages.add(package)
|
||||
validated.append({"package": package, "reason": reason.strip(), "task": task.strip()})
|
||||
return validated
|
||||
|
||||
|
||||
def analyze(root: Path = ROOT, allowlist_path: Path = DEFAULT_ALLOWLIST) -> WiringReport:
|
||||
module = read_module_path(root)
|
||||
files = discover_go_files(root)
|
||||
prod_by_dir_abs, _test_by_dir_abs = classify_files(files)
|
||||
prod_by_dir = relativize(prod_by_dir_abs, root)
|
||||
|
||||
graph = build_graph(prod_by_dir, module)
|
||||
entry_points = find_entry_points(prod_by_dir)
|
||||
reachable = reachable_from(graph, entry_points)
|
||||
|
||||
implemented_internal = {d for d in prod_by_dir if d.startswith("internal/")}
|
||||
unreachable = implemented_internal - reachable
|
||||
|
||||
allowlist_entries = load_allowlist(allowlist_path)
|
||||
|
||||
allowlisted_active: dict[str, dict[str, Any]] = {}
|
||||
stale_allowlist: dict[str, dict[str, Any]] = {}
|
||||
unknown_allowlist: dict[str, dict[str, Any]] = {}
|
||||
for entry in allowlist_entries:
|
||||
package = entry["package"]
|
||||
if package not in implemented_internal:
|
||||
unknown_allowlist[package] = entry
|
||||
elif package in reachable:
|
||||
stale_allowlist[package] = entry
|
||||
else:
|
||||
allowlisted_active[package] = entry
|
||||
|
||||
return WiringReport(
|
||||
module=module,
|
||||
entry_points=sorted(entry_points),
|
||||
implemented_internal=implemented_internal,
|
||||
reachable=reachable,
|
||||
unreachable=unreachable,
|
||||
allowlist_entries=allowlist_entries,
|
||||
allowlisted_active=allowlisted_active,
|
||||
stale_allowlist=stale_allowlist,
|
||||
unknown_allowlist=unknown_allowlist,
|
||||
)
|
||||
|
||||
|
||||
def format_report(report: WiringReport) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(f"module: {report.module}")
|
||||
lines.append(f"entry points: {', '.join(report.entry_points)}")
|
||||
reachable_internal = report.reachable & report.implemented_internal
|
||||
lines.append(
|
||||
f"reachable internal/* packages: {len(reachable_internal)} of {len(report.implemented_internal)}"
|
||||
)
|
||||
lines.append(f"allowlisted (tracked debt, still unreachable): {len(report.allowlisted_active)}")
|
||||
for package in sorted(report.allowlisted_active):
|
||||
entry = report.allowlisted_active[package]
|
||||
lines.append(f" - {package} [{entry['task']}] {entry['reason']}")
|
||||
lines.append(f"unreachable and NOT allowlisted: {len(report.failing)}")
|
||||
for package in sorted(report.failing):
|
||||
lines.append(f" - {package}")
|
||||
lines.append(f"stale allowlist entries (now reachable — remove from allowlist): {len(report.stale_allowlist)}")
|
||||
for package in sorted(report.stale_allowlist):
|
||||
lines.append(f" - {package}")
|
||||
if report.unknown_allowlist:
|
||||
lines.append(f"allowlist entries for unknown/nonexistent packages: {len(report.unknown_allowlist)}")
|
||||
for package in sorted(report.unknown_allowlist):
|
||||
lines.append(f" - {package}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
report = analyze(ROOT, DEFAULT_ALLOWLIST)
|
||||
except AllowlistError as error:
|
||||
print(f"WIRING CHECK: ERROR — {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
body = format_report(report)
|
||||
if report.ok:
|
||||
print(body)
|
||||
print(f"WIRING CHECK: PASS ({len(report.reachable & report.implemented_internal)} reachable, "
|
||||
f"{len(report.allowlisted_active)} allowlisted)")
|
||||
return 0
|
||||
|
||||
print(body, file=sys.stderr)
|
||||
print(
|
||||
f"WIRING CHECK: FAIL ({len(report.failing)} unreachable, "
|
||||
f"{len(report.stale_allowlist)} stale, {len(report.unknown_allowlist)} unknown)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent, dependency-free Pulse liveness check for an external monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
|
||||
class NoRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
def check(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
return False, "endpoint must be an http(s) URL without embedded credentials"
|
||||
if timeout < 1 or timeout > 30:
|
||||
return False, "timeout must be between 1 and 30 seconds"
|
||||
request = Request(url, headers={"Accept": "text/plain", "User-Agent": "pulse-deadman/1"}, method="GET")
|
||||
try:
|
||||
with build_opener(NoRedirectHandler).open(request, timeout=timeout) as response:
|
||||
body = response.read(64).decode("utf-8", errors="replace").strip()
|
||||
if response.status != 200 or body != "ok":
|
||||
return False, f"unexpected liveness response ({response.status})"
|
||||
except HTTPError as error:
|
||||
return False, f"liveness returned HTTP {error.code}"
|
||||
except (URLError, TimeoutError, OSError) as error:
|
||||
return False, f"liveness request failed ({error.__class__.__name__})"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check Pulse /healthz from an independent monitor")
|
||||
parser.add_argument("url", help="HTTPS URL to Pulse /healthz")
|
||||
parser.add_argument("--timeout", type=float, default=5.0)
|
||||
args = parser.parse_args()
|
||||
ok, message = check(args.url, args.timeout)
|
||||
print(message)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command integrationfixture is an isolated, deterministic external boundary
|
||||
// used only by the real-stack smoke gate. It behaves as a Prometheus-compatible
|
||||
// source and a webhook receiver; production images never include it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type counters struct {
|
||||
mu sync.Mutex
|
||||
prometheusRequests int
|
||||
webhookRequests int
|
||||
keys map[string]struct{}
|
||||
lastEvent string
|
||||
events map[string]int
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && (os.Args[1] == "health" || os.Args[1] == "get") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, os.Args[2], nil)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil || response.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if os.Args[1] == "get" {
|
||||
_, _ = io.CopyN(os.Stdout, response.Body, 64<<10)
|
||||
}
|
||||
return
|
||||
}
|
||||
listen := flag.String("listen", ":9090", "listen address")
|
||||
flag.Parse()
|
||||
state := &counters{keys: map[string]struct{}{}, events: map[string]int{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { writer.WriteHeader(http.StatusOK) })
|
||||
mux.HandleFunc("/api/v1/query", state.prometheus)
|
||||
mux.HandleFunc("/api/v1/query_range", state.prometheus)
|
||||
mux.HandleFunc("/webhook", state.webhook)
|
||||
mux.HandleFunc("/smoke/status", state.status)
|
||||
server := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 15 * time.Second}
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *counters) prometheus(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet || strings.TrimSpace(request.URL.Query().Get("query")) == "" {
|
||||
http.Error(writer, "invalid query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.prometheusRequests++
|
||||
state.mu.Unlock()
|
||||
now := float64(time.Now().UTC().Unix())
|
||||
data := map[string]any{"resultType": "vector", "result": []any{map[string]any{"metric": map[string]string{"instance": "smoke-host"}, "value": []any{now, "95"}}}}
|
||||
if request.URL.Path == "/api/v1/query_range" {
|
||||
data = map[string]any{"resultType": "matrix", "result": []any{map[string]any{"metric": map[string]string{"instance": "smoke-host"}, "values": []any{[]any{now - 60, "90"}, []any{now, "95"}}}}}
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"status": "success", "data": data})
|
||||
}
|
||||
|
||||
func (state *counters) webhook(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
writer.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
expected := os.Getenv("PULSE_SMOKE_WEBHOOK_TOKEN")
|
||||
if expected == "" || request.Header.Get("Authorization") != "Bearer "+expected {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
key := request.Header.Get("Idempotency-Key")
|
||||
var payload struct {
|
||||
EventType string `json:"eventType"`
|
||||
}
|
||||
if key == "" || json.NewDecoder(http.MaxBytesReader(writer, request.Body, 16<<10)).Decode(&payload) != nil {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
state.webhookRequests++
|
||||
state.keys[key] = struct{}{}
|
||||
state.lastEvent = payload.EventType
|
||||
state.events[payload.EventType]++
|
||||
state.mu.Unlock()
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (state *counters) status(writer http.ResponseWriter, _ *http.Request) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"prometheusRequests": state.prometheusRequests, "webhookRequests": state.webhookRequests, "uniqueDeliveryKeys": len(state.keys), "lastEventType": state.lastEvent, "events": state.events})
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate starter JSON Schemas and their canonical examples.
|
||||
|
||||
Requires the development-only `jsonschema` package. The core `projectctl.py`
|
||||
remains standard-library-only so repository state can be recovered first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
from jsonschema import Draft202012Validator
|
||||
from referencing import Registry, Resource
|
||||
except ModuleNotFoundError:
|
||||
print(
|
||||
"ERROR: contract validation requires the development package 'jsonschema'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
schema_paths = sorted((ROOT / "specs").glob("*.json"))
|
||||
schemas: dict[str, dict[str, Any]] = {}
|
||||
resources: list[tuple[str, Any]] = []
|
||||
|
||||
for path in schema_paths:
|
||||
schema = load(path)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
schema_id = schema.get("$id")
|
||||
if not isinstance(schema_id, str) or not schema_id:
|
||||
print(f"ERROR: {path.relative_to(ROOT)} has no $id", file=sys.stderr)
|
||||
return 1
|
||||
schemas[path.relative_to(ROOT).as_posix()] = schema
|
||||
resource = Resource.from_contents(schema)
|
||||
resources.append((schema_id, resource))
|
||||
resources.append((path.resolve().as_uri(), resource))
|
||||
|
||||
registry = Registry().with_resources(resources)
|
||||
checks: list[tuple[str, str]] = [
|
||||
("config/metrics/catalog.example.json", "specs/metric-catalog.schema.json"),
|
||||
("config/dashboards/default-overview.example.json", "specs/dashboard.schema.json"),
|
||||
("config/alerts/default-rules.example.json", "specs/alert-rule-set.schema.json"),
|
||||
("config/probes/probe.example.json", "specs/probe.schema.json"),
|
||||
]
|
||||
# The canonical private repository validates its implementation ledger. The
|
||||
# curated public-source export deliberately excludes private planning state.
|
||||
if (ROOT / "planning" / "task-ledger.json").exists():
|
||||
checks.insert(0, ("planning/task-ledger.json", "specs/task-ledger.schema.json"))
|
||||
checks.extend(
|
||||
(path.relative_to(ROOT).as_posix(), "specs/simulator-scenario.schema.json")
|
||||
for path in sorted((ROOT / "fixtures" / "scenarios").glob("*.json"))
|
||||
)
|
||||
|
||||
failures = 0
|
||||
for document_name, schema_name in checks:
|
||||
document = load(ROOT / document_name)
|
||||
schema = schemas[schema_name]
|
||||
validator = Draft202012Validator(schema, registry=registry)
|
||||
errors = sorted(validator.iter_errors(document), key=lambda item: list(item.path))
|
||||
if not errors:
|
||||
print(f"PASS: {document_name}")
|
||||
continue
|
||||
failures += len(errors)
|
||||
print(f"FAIL: {document_name}", file=sys.stderr)
|
||||
for error in errors:
|
||||
location = "/".join(str(part) for part in error.path) or "<root>"
|
||||
print(f"- {location}: {error.message}", file=sys.stderr)
|
||||
|
||||
if failures:
|
||||
print(f"CONTRACT VALIDATION: FAIL ({failures} errors)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"CONTRACT VALIDATION: PASS ({len(schema_paths)} schemas, {len(checks)} documents)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
export function percentile(values, fraction) {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))];
|
||||
}
|
||||
|
||||
export function median(values) {
|
||||
return percentile(values, 0.5);
|
||||
}
|
||||
|
||||
export function linearSlopePerHour(samples, field) {
|
||||
if (samples.length < 2) return 0;
|
||||
const xs = samples.map((sample) => sample.elapsedMs / 3_600_000);
|
||||
const ys = samples.map((sample) => sample[field]);
|
||||
const meanX = xs.reduce((sum, value) => sum + value, 0) / xs.length;
|
||||
const meanY = ys.reduce((sum, value) => sum + value, 0) / ys.length;
|
||||
const denominator = xs.reduce((sum, value) => sum + ((value - meanX) ** 2), 0);
|
||||
if (denominator === 0) return 0;
|
||||
return xs.reduce((sum, value, index) => sum + ((value - meanX) * (ys[index] - meanY)), 0) / denominator;
|
||||
}
|
||||
|
||||
export function buildSoakSummary({ samples, reconnects, startedAt, completedAt, durationHours, sampleSeconds, counters, requireWallboardTitle = false }) {
|
||||
if (samples.length === 0) throw new Error('at least one wallboard sample is required');
|
||||
const hourly = samples.filter((sample) => sample.forcedGC);
|
||||
if (hourly.length === 0) throw new Error('at least one forced-GC sample is required');
|
||||
const quarterSize = Math.max(1, Math.ceil(hourly.length / 4));
|
||||
const firstQuarter = hourly.slice(0, quarterSize).map((sample) => sample.usedHeapBytes);
|
||||
const lastQuarter = hourly.slice(-quarterSize).map((sample) => sample.usedHeapBytes);
|
||||
const retainedGrowthBytes = median(lastQuarter) - median(firstQuarter);
|
||||
const heapSlopeBytesPerHour = linearSlopePerHour(hourly, 'usedHeapBytes');
|
||||
const maxAllowedGrowthBytes = Math.max(16 * 1024 * 1024, median(firstQuarter) * 0.20);
|
||||
const elapsedHours = (completedAt.getTime() - startedAt.getTime()) / 3_600_000;
|
||||
const expectedSampleCount = durationHours * 3_600 / sampleSeconds;
|
||||
const minimumSampleCount = Math.floor(Math.min(expectedSampleCount * 0.99, Math.max(1, expectedSampleCount - 2)));
|
||||
const sampleGapsSeconds = samples.slice(1).map((sample, index) => (sample.elapsedMs - samples[index].elapsedMs) / 1_000);
|
||||
const maxSampleGapSeconds = Math.max(0, ...sampleGapsSeconds);
|
||||
const maxAllowedSampleGapSeconds = sampleSeconds + 15;
|
||||
const summary = {
|
||||
status: 'pass',
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: completedAt.toISOString(),
|
||||
elapsedHours,
|
||||
sampleCount: samples.length,
|
||||
forcedGCSampleCount: hourly.length,
|
||||
expectedSampleCount,
|
||||
minimumSampleCount,
|
||||
maxSampleGapSeconds,
|
||||
maxAllowedSampleGapSeconds,
|
||||
reconnectCount: reconnects.length,
|
||||
reconnectFailures: reconnects.filter((item) => !item.recovered).length,
|
||||
reconnectP95Ms: percentile(reconnects.map((item) => item.durationMs), 0.95),
|
||||
retainedGrowthBytes,
|
||||
heapSlopeBytesPerHour,
|
||||
maxAllowedGrowthBytes,
|
||||
fpsP05: percentile(samples.map((sample) => sample.fps), 0.05),
|
||||
frameP95WorstMs: Math.max(...samples.map((sample) => sample.frameP95Ms)),
|
||||
longestTaskMs: Math.max(...samples.map((sample) => sample.longestTaskMs)),
|
||||
maxActiveSockets: counters.maxActiveSockets,
|
||||
openedSockets: counters.openedSockets,
|
||||
closedSockets: counters.closedSockets,
|
||||
websocketErrors: counters.websocketErrors ?? 0,
|
||||
socketChurnBudget: reconnects.length + 4,
|
||||
pageErrors: counters.pageErrors,
|
||||
apiFailures: counters.apiFailures,
|
||||
maxDOMNodes: Math.max(...samples.map((sample) => sample.nodes)),
|
||||
horizontalOverflowSamples: samples.filter((sample) => sample.bodyWidth > sample.viewportWidth).length,
|
||||
verticalOverflowSamples: samples.filter((sample) => sample.bodyHeight != null && sample.viewportHeight != null && sample.bodyHeight > sample.viewportHeight).length,
|
||||
invalidWallboardSamples: samples.filter((sample) => {
|
||||
if (sample.wallboardTitle == null || sample.activeDashboard == null) return requireWallboardTitle;
|
||||
return sample.wallboardTitle !== 'Operationeel wallboard' || !/^Wallboard soak [12]$/.test(sample.activeDashboard);
|
||||
}).length,
|
||||
};
|
||||
const failures = [];
|
||||
if (summary.elapsedHours < durationHours) failures.push('elapsed duration shorter than requested');
|
||||
if (summary.sampleCount < minimumSampleCount || maxSampleGapSeconds > maxAllowedSampleGapSeconds) failures.push('sample coverage or continuity fell below the uninterrupted-run budget');
|
||||
if (retainedGrowthBytes > maxAllowedGrowthBytes || heapSlopeBytesPerHour > 1024 * 1024) failures.push('retained heap growth exceeded the bounded budget');
|
||||
if (summary.reconnectFailures > 0 || summary.reconnectP95Ms > 10_000) failures.push('WebSocket reconnect exceeded 10 seconds');
|
||||
if (summary.frameP95WorstMs > 50 || summary.fpsP05 < 30 || summary.longestTaskMs > 250) failures.push('visible-jank budget exceeded');
|
||||
if (summary.maxActiveSockets > 2) failures.push('active WebSocket count was unbounded');
|
||||
if (summary.openedSockets > summary.socketChurnBudget) failures.push('WebSocket lifecycle churn exceeded the bounded budget');
|
||||
if (summary.websocketErrors > 0 || summary.pageErrors > 0 || summary.apiFailures > 0) failures.push('runtime browser, API or WebSocket failures occurred');
|
||||
if (summary.horizontalOverflowSamples > 0 || summary.verticalOverflowSamples > 0) failures.push('wallboard overflowed the viewport');
|
||||
if (summary.invalidWallboardSamples > 0) failures.push('wallboard content disappeared during the soak');
|
||||
if (failures.length > 0) {
|
||||
summary.status = 'fail';
|
||||
summary.failures = failures;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { appendFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { buildSoakSummary } from './wallboard-soak-analysis.mjs';
|
||||
|
||||
const requireFromWebWorkspace = createRequire(new URL('../apps/web/package.json', import.meta.url));
|
||||
const { chromium } = requireFromWebWorkspace('@playwright/test');
|
||||
|
||||
const baseURL = process.env.PULSE_SOAK_BASE_URL;
|
||||
const outputDirectory = path.resolve(process.env.PULSE_SOAK_OUTPUT_DIR ?? 'artifacts/evidence/M10-14/raw');
|
||||
const durationHours = Number(process.env.PULSE_SOAK_DURATION_HOURS ?? '24');
|
||||
const sampleSeconds = Number(process.env.PULSE_SOAK_SAMPLE_SECONDS ?? '60');
|
||||
const reconnectSeconds = Number(process.env.PULSE_SOAK_RECONNECT_SECONDS ?? '3600');
|
||||
const dashboardIDs = ['51000000-0000-4000-8000-000000000001', '51000000-0000-4000-8000-000000000002'];
|
||||
|
||||
if (!baseURL || !Number.isFinite(durationHours) || durationHours <= 0 || !Number.isFinite(sampleSeconds) || sampleSeconds < 5 || !Number.isFinite(reconnectSeconds) || reconnectSeconds < 30) {
|
||||
throw new Error('PULSE_SOAK_BASE_URL and positive bounded soak durations are required');
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const samplesPath = path.join(outputDirectory, 'samples.ndjson');
|
||||
const reconnectsPath = path.join(outputDirectory, 'reconnects.ndjson');
|
||||
const eventsPath = path.join(outputDirectory, 'events.ndjson');
|
||||
const metadataPath = path.join(outputDirectory, 'metadata.json');
|
||||
const summaryPath = path.join(outputDirectory, 'result.json');
|
||||
const startedAt = new Date();
|
||||
const durationMs = durationHours * 60 * 60 * 1000;
|
||||
const endAt = startedAt.getTime() + durationMs;
|
||||
|
||||
let eventWrite = Promise.resolve();
|
||||
const writeEvent = (type, details = {}) => {
|
||||
eventWrite = eventWrite.then(() => appendFile(eventsPath, `${JSON.stringify({ timestamp: new Date().toISOString(), type, ...details })}\n`));
|
||||
return eventWrite;
|
||||
};
|
||||
|
||||
function dashboardDocument(id, index) {
|
||||
const widgetID = `52000000-0000-4000-8000-00000000000${index}`;
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
id,
|
||||
slug: `wallboard-soak-${index}`,
|
||||
name: `Wallboard soak ${index}`,
|
||||
description: 'Echte 24-uursmeting met begrensde live CPU-data.',
|
||||
// Mock login intentionally does not provision a durable user row. A
|
||||
// system-scoped soak dashboard stays visible to the same read principal
|
||||
// without weakening the production ownership query.
|
||||
scope: 'system',
|
||||
variables: [],
|
||||
widgets: [{
|
||||
id: widgetID,
|
||||
type: 'timeseries',
|
||||
title: 'CPU live',
|
||||
description: 'Prometheus-query en WebSocket live-buffer.',
|
||||
data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: 'smoke-host' }, aggregation: 'avg', groupBy: ['instance'], transformations: [] },
|
||||
visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] },
|
||||
behavior: { locked: true, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 2, independentTimeRange: null },
|
||||
layouts: {
|
||||
desktop: { x: 0, y: 0, w: 18, h: 8, visible: true },
|
||||
tablet: { x: 0, y: 0, w: 8, h: 8, visible: true },
|
||||
mobile: { x: 0, y: 0, w: 1, h: 8, visible: true },
|
||||
wallboard: { x: 0, y: 0, w: 24, h: 12, visible: true },
|
||||
},
|
||||
}],
|
||||
settings: { defaultTimeRange: 'live', live: true, refreshSeconds: 10, rotationSeconds: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--enable-precise-memory-info', '--disable-background-timer-throttling', '--disable-renderer-backgrounding'] });
|
||||
const context = await browser.newContext({ baseURL, viewport: { width: 1920, height: 1080 }, locale: 'nl-BE' });
|
||||
let page;
|
||||
const samples = [];
|
||||
const reconnects = [];
|
||||
let activeSockets = 0;
|
||||
let maxActiveSockets = 0;
|
||||
let openedSockets = 0;
|
||||
let closedSockets = 0;
|
||||
let websocketErrors = 0;
|
||||
let pageErrors = 0;
|
||||
let apiFailures = 0;
|
||||
|
||||
try {
|
||||
const login = await context.request.get('/auth/test-login');
|
||||
if (!login.ok()) throw new Error(`mock login failed with ${login.status()}`);
|
||||
for (let index = 0; index < dashboardIDs.length; index += 1) {
|
||||
const response = await context.request.post('/api/v1/dashboards', { data: dashboardDocument(dashboardIDs[index], index + 1) });
|
||||
if (!response.ok() && response.status() !== 409) throw new Error(`dashboard seed failed with ${response.status()}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
await context.addInitScript((ids) => {
|
||||
for (const id of ids) window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live');
|
||||
window.__pulseSoak = { longTasks: [], sockets: new Set(), startedAt: Date.now() };
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
window.WebSocket = class SoakObservableWebSocket extends NativeWebSocket {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
window.__pulseSoak.sockets.add(this);
|
||||
this.addEventListener('close', () => window.__pulseSoak.sockets.delete(this), { once: true });
|
||||
}
|
||||
};
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) window.__pulseSoak.longTasks.push({ at: Date.now(), duration: entry.duration });
|
||||
});
|
||||
try { observer.observe({ type: 'longtask', buffered: true }); } catch { /* unsupported browsers remain measurable through frame deltas */ }
|
||||
}
|
||||
}, dashboardIDs);
|
||||
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', (error) => { pageErrors += 1; void writeEvent('page-error', { message: error.message.slice(0, 300) }); });
|
||||
page.on('response', (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) {
|
||||
apiFailures += 1;
|
||||
void writeEvent('api-failure', { status: response.status(), path: new URL(response.url()).pathname });
|
||||
}
|
||||
});
|
||||
page.on('websocket', (socket) => {
|
||||
openedSockets += 1;
|
||||
activeSockets += 1;
|
||||
maxActiveSockets = Math.max(maxActiveSockets, activeSockets);
|
||||
void writeEvent('websocket-open', { url: new URL(socket.url()).pathname, activeSockets });
|
||||
socket.on('close', () => {
|
||||
activeSockets = Math.max(0, activeSockets - 1);
|
||||
closedSockets += 1;
|
||||
void writeEvent('websocket-close', { activeSockets });
|
||||
});
|
||||
socket.on('socketerror', (error) => {
|
||||
websocketErrors += 1;
|
||||
void writeEvent('websocket-error', { message: String(error).slice(0, 300) });
|
||||
});
|
||||
});
|
||||
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
await page.goto('/wallboard?interval=10&refresh=10', { waitUntil: 'networkidle' });
|
||||
await page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' }).waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.getByText(/Wallboard soak [12]/).first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await writeFile(metadataPath, `${JSON.stringify({
|
||||
startedAt: startedAt.toISOString(),
|
||||
plannedEndAt: new Date(endAt).toISOString(),
|
||||
durationHours,
|
||||
sampleSeconds,
|
||||
reconnectSeconds,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
browser: await browser.version(),
|
||||
baseURL: new URL(baseURL).origin,
|
||||
dashboards: dashboardIDs.length,
|
||||
}, null, 2)}\n`);
|
||||
await writeEvent('soak-started');
|
||||
|
||||
let nextSampleAt = Date.now();
|
||||
let nextReconnectAt = startedAt.getTime() + reconnectSeconds * 1000;
|
||||
let nextGCAt = startedAt.getTime();
|
||||
let sampleNumber = 0;
|
||||
while (Date.now() < endAt) {
|
||||
const now = Date.now();
|
||||
if (now >= nextReconnectAt) {
|
||||
const outageMs = 500;
|
||||
const reconnectStarted = Date.now();
|
||||
const openedBefore = openedSockets;
|
||||
await context.setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
for (const socket of window.__pulseSoak.sockets) socket.close(4000, 'scheduled soak disconnect');
|
||||
});
|
||||
await page.waitForTimeout(outageMs);
|
||||
await context.setOffline(false);
|
||||
let recovered = false;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (openedSockets > openedBefore && activeSockets > 0) { recovered = true; break; }
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
const reconnect = { timestamp: new Date().toISOString(), elapsedMs: Date.now() - startedAt.getTime(), outageMs, durationMs: Date.now() - reconnectStarted - outageMs, recovered, openedBefore, openedAfter: openedSockets, activeSockets };
|
||||
reconnects.push(reconnect);
|
||||
await appendFile(reconnectsPath, `${JSON.stringify(reconnect)}\n`);
|
||||
nextReconnectAt += reconnectSeconds * 1000;
|
||||
}
|
||||
|
||||
if (now >= nextSampleAt) {
|
||||
sampleNumber += 1;
|
||||
const elapsedMs = now - startedAt.getTime();
|
||||
const forcedGC = now >= nextGCAt;
|
||||
if (forcedGC) {
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
nextGCAt += 3_600_000;
|
||||
}
|
||||
const [{ metrics }, heap, frame] = await Promise.all([
|
||||
cdp.send('Performance.getMetrics'),
|
||||
cdp.send('Runtime.getHeapUsage'),
|
||||
page.evaluate(async () => {
|
||||
const deltas = [];
|
||||
const started = performance.now();
|
||||
let previous = started;
|
||||
await new Promise((resolve) => {
|
||||
const tick = (timestamp) => {
|
||||
deltas.push(timestamp - previous);
|
||||
previous = timestamp;
|
||||
if (timestamp - started >= 2_000) resolve(); else requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
const longTasks = window.__pulseSoak?.longTasks?.splice(0) ?? [];
|
||||
return {
|
||||
frameCount: deltas.length,
|
||||
frameDurationMs: performance.now() - started,
|
||||
frameP95Ms: deltas.sort((a, b) => a - b)[Math.min(deltas.length - 1, Math.floor(deltas.length * 0.95))] ?? 0,
|
||||
framesOver50Ms: deltas.filter((value) => value > 50).length,
|
||||
longTaskCount: longTasks.length,
|
||||
longestTaskMs: longTasks.reduce((max, task) => Math.max(max, task.duration), 0),
|
||||
domNodes: document.getElementsByTagName('*').length,
|
||||
bodyWidth: document.body.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
bodyHeight: document.documentElement.scrollHeight,
|
||||
viewportHeight: window.innerHeight,
|
||||
wallboardTitle: document.querySelector('#wallboard-title')?.textContent ?? '',
|
||||
activeDashboard: document.querySelector('#dashboard-view-title')?.textContent ?? '',
|
||||
};
|
||||
}),
|
||||
]);
|
||||
const metricMap = Object.fromEntries(metrics.map((metric) => [metric.name, metric.value]));
|
||||
const sample = {
|
||||
sequence: sampleNumber,
|
||||
timestamp: new Date().toISOString(),
|
||||
elapsedMs,
|
||||
forcedGC,
|
||||
usedHeapBytes: heap.usedSize,
|
||||
totalHeapBytes: heap.totalSize,
|
||||
jsHeapUsedBytes: metricMap.JSHeapUsedSize ?? null,
|
||||
jsHeapTotalBytes: metricMap.JSHeapTotalSize ?? null,
|
||||
nodes: metricMap.Nodes ?? frame.domNodes,
|
||||
documents: metricMap.Documents ?? null,
|
||||
listeners: metricMap.JSEventListeners ?? null,
|
||||
layoutCount: metricMap.LayoutCount ?? null,
|
||||
recalcStyleCount: metricMap.RecalcStyleCount ?? null,
|
||||
taskDurationSeconds: metricMap.TaskDuration ?? null,
|
||||
fps: frame.frameDurationMs > 0 ? (frame.frameCount * 1000) / frame.frameDurationMs : 0,
|
||||
frameP95Ms: frame.frameP95Ms,
|
||||
framesOver50Ms: frame.framesOver50Ms,
|
||||
longTaskCount: frame.longTaskCount,
|
||||
longestTaskMs: frame.longestTaskMs,
|
||||
bodyWidth: frame.bodyWidth,
|
||||
viewportWidth: frame.viewportWidth,
|
||||
bodyHeight: frame.bodyHeight,
|
||||
viewportHeight: frame.viewportHeight,
|
||||
activeSockets,
|
||||
openedSockets,
|
||||
closedSockets,
|
||||
pageErrors,
|
||||
apiFailures,
|
||||
wallboardTitle: frame.wallboardTitle,
|
||||
activeDashboard: frame.activeDashboard,
|
||||
};
|
||||
samples.push(sample);
|
||||
await appendFile(samplesPath, `${JSON.stringify(sample)}\n`);
|
||||
nextSampleAt += sampleSeconds * 1000;
|
||||
}
|
||||
await page.waitForTimeout(Math.max(100, Math.min(1_000, Math.min(nextSampleAt, nextReconnectAt, endAt) - Date.now())));
|
||||
}
|
||||
|
||||
const summary = buildSoakSummary({
|
||||
samples,
|
||||
reconnects,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
durationHours,
|
||||
sampleSeconds,
|
||||
counters: { maxActiveSockets, openedSockets, closedSockets, websocketErrors, pageErrors, apiFailures },
|
||||
requireWallboardTitle: true,
|
||||
});
|
||||
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
|
||||
await writeEvent('soak-completed', { status: summary.status });
|
||||
if (summary.status !== 'pass') throw new Error(`wallboard soak failed: ${summary.failures.join('; ')}`);
|
||||
} catch (error) {
|
||||
await writeEvent('soak-failed', { message: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"description": "Packages that tools/check_wiring.py has confirmed are implemented but not yet reachable from a running binary (cmd/api, cmd/worker, cmd/agent, cmd/migrate). Every entry requires a 'reason' and a tracking 'task' id — this list makes wiring debt visible, it does not forgive it. Remove an entry as soon as the referenced task wires its package to an entry point; check_wiring.py reports any entry that has become reachable as stale and fails until it is removed.",
|
||||
"entries": []
|
||||
}
|
||||
Reference in New Issue
Block a user