Files
pulse/scripts/collect_e2e_container_logs.py
pulse-triage[bot] e2a3ce4bc2 Retain E2E container diagnostics without flooding Actions logs
A completed E2E log read exceeded the enforced eight-million-byte output bound. Keep complete fixed-container stdout/stderr and exact source/run/hash receipts in short-lived artifacts, emit only bounded escaped console tails, and retain nonzero, timed-out and unavailable collection results without suppressing them. Preserve every test tier, retry policy and existing enforcement bound.

Contract-Neutral: CI-only diagnostic retention; product runtime, source-image admission and stable/probation test contracts are unchanged.
Change-source: pulse-maintainer
2026-10-10 19:00:17 +01:00

105 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Retain CI container diagnostics without flooding the combined Actions log.
Full stdout/stderr belongs in this run's short-lived artifact. The console gets
only a bounded, escaped tail, byte/hash identity and an honest collection result.
This observes fixed disposable E2E containers; it never starts or changes one.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
TAIL_BYTES = 4096
COMMAND_TIMEOUT_SECONDS = 30
COMMANDS = (
("containers.txt", ("docker", "ps", "-a")),
("pulse-test-server.log", ("docker", "logs", "pulse-test-server")),
("pulse-mock-github.log", ("docker", "logs", "pulse-mock-github")),
)
def checkout_identity():
identity = {
"source_sha": os.environ["GITHUB_SHA"],
"run_id": os.environ["GITHUB_RUN_ID"],
"run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
}
if not re.fullmatch(r"[0-9a-f]{40}", identity["source_sha"]):
raise ValueError("invalid E2E source identity")
if any(not re.fullmatch(r"[1-9][0-9]*", identity[key])
for key in ("run_id", "run_attempt")):
raise ValueError("invalid E2E run identity")
source = subprocess.run(
["git", "rev-parse", "HEAD"], check=True, capture_output=True,
text=True, timeout=COMMAND_TIMEOUT_SECONDS,
).stdout.strip()
if source != identity["source_sha"]:
raise ValueError("E2E checkout differs from this run")
return identity
def file_observation(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(65536), b""):
digest.update(block)
size = stream.tell()
stream.seek(max(0, size - TAIL_BYTES))
tail = stream.read(TAIL_BYTES)
return size, digest.hexdigest(), tail
def collect(directory, identity, *, registration=False, run=subprocess.run, console=None):
console = sys.stdout if console is None else console
# Never overwrite another attempt's evidence, including through a symlink.
directory.mkdir(parents=True, exist_ok=False)
records = []
for filename, command in COMMANDS[:2] if registration else COMMANDS:
path = directory / filename
record = {"file": filename, "exit_code": None, "collection_complete": False}
with path.open("xb") as stream:
try:
result = run(command, stdout=stream, stderr=subprocess.STDOUT,
timeout=COMMAND_TIMEOUT_SECONDS, check=False)
record.update(exit_code=result.returncode,
collection_complete=result.returncode == 0)
except subprocess.TimeoutExpired:
# subprocess.run stops and waits for its Docker CLI on timeout;
# TimeoutExpired exposes no terminal exit. Do not invent one.
record["failure"] = "timeout"
except OSError:
record["failure"] = "spawn"
size, digest, tail = file_observation(path)
record.update(bytes=size, sha256=digest)
records.append(record)
# JSON escaping prevents log text becoming Actions commands. Never
# mistake this bounded excerpt for the complete artifact bytes.
print(json.dumps({**record, "console_tail_bytes": len(tail),
"console_tail_truncated": size > len(tail),
"console_tail": tail.decode("utf-8", errors="replace")}),
file=console, flush=True)
complete = all(record["collection_complete"] for record in records)
manifest = {"schema_version": 1, **identity, "collection_complete": complete,
"records": records}
(directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
return 0 if complete else 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path)
parser.add_argument("--registration", action="store_true")
arguments = parser.parse_args()
return collect(arguments.output, checkout_identity(), registration=arguments.registration)
if __name__ == "__main__":
raise SystemExit(main())