trainlog/tools/validate_sync_isolated.py

539 lines
18 KiB
Python
Executable file

#!/usr/bin/env python3
"""Run Trainlog sync validation with private HOME/XDG/tmp data paths.
This harness isolates application data and test transports by environment and
explicit fixture paths. It is not a system sandbox and intentionally reuses the
already-provisioned Gradle dependency cache.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
SUCCESS = 0
PREFLIGHT_BLOCKED = 2
DEPENDENCY_MISSING = 3
ENVIRONMENT_FAILURE = 4
TEST_FAILURE = 5
INTERRUPTED = 130
MAX_LOG_BYTES = 8 * 1024 * 1024
TARGET_ANDROID_TEST = (
"com.labfytools.trainlog.data.TrainlogRepositoryDraftTest."
"finalizeIsAtomicAndDraftNeverExportsBeforeCompletion"
)
ENV_ANDROID_TEST = (
"com.labfytools.trainlog.data.IsolatedTestEnvironmentTest."
"private paths reach the Robolectric JVM"
)
class HarnessError(RuntimeError):
def __init__(self, message: str, exit_code: int) -> None:
super().__init__(message)
self.exit_code = exit_code
@dataclass(frozen=True)
class RunPaths:
root: Path
home: Path
data: Path
config: Path
cache: Path
runtime: Path
native_tmp: Path
exchange: Path
databases: Path
reports: Path
def parse_java_major(output: str) -> int | None:
match = re.search(r'(?:java|openjdk) version "([0-9]+)(?:\.|\")', output)
return int(match.group(1)) if match else None
def resolve_jdk(explicit: str | None, inherited: dict[str, str]) -> Path:
candidates: list[Path] = []
if explicit:
candidates.append(Path(explicit))
else:
if inherited.get("JAVA_HOME"):
candidates.append(Path(inherited["JAVA_HOME"]))
candidates.extend(
[Path("/usr/lib/jvm/java-17-openjdk"), Path("/usr/lib/jvm/java-17-openjdk-amd64")]
)
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.expanduser().resolve()
if resolved in seen:
continue
seen.add(resolved)
java = resolved / "bin/java"
if not java.is_file() or not os.access(java, os.X_OK):
continue
result = subprocess.run(
[str(java), "-version"], capture_output=True, text=True, check=False
)
version_output = result.stdout + result.stderr
if result.returncode == 0 and parse_java_major(version_output) == 17:
return resolved
if explicit:
raise HarnessError(
f"JDK incompatible: {resolved} n'est pas un JDK 17 utilisable",
PREFLIGHT_BLOCKED,
)
raise HarnessError(
"JDK 17 introuvable; utiliser --jdk /chemin/vers/jdk17",
DEPENDENCY_MISSING,
)
def default_run_parent(inherited: dict[str, str]) -> Path:
cache = inherited.get("XDG_CACHE_HOME")
if cache:
base = Path(cache).expanduser()
else:
home = inherited.get("HOME")
if not home:
raise HarnessError("HOME est absent", PREFLIGHT_BLOCKED)
base = Path(home).expanduser() / ".cache"
return base.resolve() / "trainlog/test-runs"
def check_parent(parent: Path) -> Path:
try:
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
parent = parent.resolve(strict=True)
if not parent.is_dir() or not os.access(parent, os.W_OK | os.X_OK):
raise OSError("répertoire non accessible en écriture")
probe = parent / f".write-probe-{os.getpid()}"
descriptor = os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.write(descriptor, b"trainlog-test\n")
os.close(descriptor)
probe.unlink()
except OSError as error:
raise HarnessError(
f"parent temporaire inutilisable: {parent}: {error}",
ENVIRONMENT_FAILURE,
) from error
return parent
def create_run_paths(parent: Path) -> RunPaths:
os.umask(0o077)
root = Path(tempfile.mkdtemp(prefix="run-", dir=check_parent(parent))).resolve()
names = {
"home": "home",
"data": "xdg-data",
"config": "xdg-config",
"cache": "xdg-cache",
"runtime": "xdg-runtime",
"native_tmp": "tmp",
"exchange": "exchange",
"databases": "databases",
"reports": "reports",
}
created: dict[str, Path] = {}
for key, name in names.items():
path = root / name
path.mkdir(mode=0o700)
path.chmod(0o700)
created[key] = path
return RunPaths(root=root, **created)
def build_environment(
paths: RunPaths, jdk: Path, inherited: dict[str, str], gradle_cache: Path
) -> dict[str, str]:
environment = inherited.copy()
environment.update(
{
"HOME": str(paths.home),
"XDG_DATA_HOME": str(paths.data),
"XDG_CONFIG_HOME": str(paths.config),
"XDG_CACHE_HOME": str(paths.cache),
"XDG_RUNTIME_DIR": str(paths.runtime),
"TMPDIR": str(paths.native_tmp),
"JAVA_HOME": str(jdk),
"GRADLE_USER_HOME": str(gradle_cache),
"JAVA_TOOL_OPTIONS": f"-Djava.io.tmpdir={paths.native_tmp}",
"TRAINLOG_TEST_RUN_ROOT": str(paths.root),
"TRAINLOG_TEST_TMPDIR": str(paths.native_tmp),
"TRAINLOG_TEST_EXCHANGE_DIR": str(paths.exchange),
"TRAINLOG_TEST_DATABASE_DIR": str(paths.databases),
}
)
# CONTRACT: real rclone configuration and production daemon endpoints are
# not inherited by an isolated validation run.
environment.pop("RCLONE_CONFIG", None)
return environment
def command_label(arguments: list[str]) -> str:
return " ".join(json.dumps(argument) for argument in arguments)
def classify_command_failure(log_path: Path) -> int:
output = log_path.read_text(encoding="utf-8", errors="replace").lower()
environment_markers = (
"disk quota exceeded",
"no space left on device",
"unable to load robolectric native runtime library",
"could not create the java virtual machine",
)
return (
ENVIRONMENT_FAILURE
if any(marker in output for marker in environment_markers)
else TEST_FAILURE
)
def run_command(
arguments: list[str],
*,
cwd: Path,
environment: dict[str, str],
log_path: Path,
active: set[subprocess.Popen[bytes]],
) -> None:
log_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with log_path.open("wb") as log:
os.chmod(log_path, 0o600)
header = f"COMMAND {command_label(arguments)}\nCWD {cwd}\n".encode()
log.write(header)
try:
process = subprocess.Popen(
arguments,
cwd=cwd,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
except FileNotFoundError as error:
log.write(f"DEPENDENCY_MISSING {error}\n".encode())
raise HarnessError(
f"outil ou dépendance manquante: {arguments[0]}", DEPENDENCY_MISSING
) from error
active.add(process)
captured = len(header)
try:
assert process.stdout is not None
while True:
chunk = process.stdout.read(65536)
if not chunk:
break
if captured < MAX_LOG_BYTES:
retained = chunk[: MAX_LOG_BYTES - captured]
log.write(retained)
captured += len(retained)
process.stdout.close()
return_code = process.wait()
finally:
# INVARIANT: an interrupted live child remains registered so the
# caller can terminate its owned process group before returning.
if process.poll() is not None:
active.discard(process)
log.write(f"\nEXIT {return_code}\n".encode())
if return_code != 0:
exit_code = classify_command_failure(log_path)
raise HarnessError(
f"commande en échec ({return_code}): {command_label(arguments)}; log={log_path}",
exit_code,
)
def stop_children(active: set[subprocess.Popen[bytes]]) -> None:
for process in tuple(active):
if process.poll() is None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
deadline = time.monotonic() + 5.0
for process in tuple(active):
remaining = max(0.0, deadline - time.monotonic())
try:
process.wait(timeout=remaining)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
active.discard(process)
def safe_cleanup(root: Path, parent: Path) -> None:
resolved_parent = parent.resolve(strict=True)
resolved_root = root.resolve(strict=True)
if resolved_root.parent != resolved_parent or not resolved_root.name.startswith("run-"):
raise HarnessError(
f"refus de nettoyer une cible extérieure: {resolved_root}",
ENVIRONMENT_FAILURE,
)
for entry in resolved_root.rglob("*"):
if entry.is_symlink():
raise HarnessError(
f"refus de suivre/nettoyer le symlink: {entry}", ENVIRONMENT_FAILURE
)
shutil.rmtree(resolved_root)
def count_android_results(result_dir: Path) -> dict[str, int]:
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
files = list(result_dir.glob("TEST-*.xml"))
if not files:
raise HarnessError(
f"aucun résultat Android dans {result_dir}", ENVIRONMENT_FAILURE
)
for result_file in files:
suite = ET.parse(result_file).getroot()
for key in totals:
totals[key] += int(suite.attrib.get(key, "0"))
totals["passed"] = (
totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"]
)
return totals
def verify_gradle_jdk(
repository: Path,
environment: dict[str, str],
reports: Path,
active: set[subprocess.Popen[bytes]],
) -> None:
log = reports / "gradle-version.log"
run_command(
[str(repository / "android/gradlew"), "--no-daemon", "--version"],
cwd=repository / "android",
environment=environment,
log_path=log,
active=active,
)
output = log.read_text(encoding="utf-8", errors="replace")
match = re.search(r"Launcher JVM:\s+17(?:\.|\s)", output)
java_home = Path(environment["JAVA_HOME"]).resolve()
daemon_line = re.search(r"^Daemon JVM:\s+(.+)$", output, re.MULTILINE)
daemon = daemon_line is not None and str(java_home) in daemon_line.group(1)
if not match or not daemon:
raise HarnessError(
f"Gradle n'utilise pas JDK 17 (voir {log})", ENVIRONMENT_FAILURE
)
def required_tools(repository: Path) -> None:
for executable in ("meson", "python3"):
if shutil.which(executable) is None:
raise HarnessError(f"outil manquant: {executable}", DEPENDENCY_MISSING)
for path in (
repository / "android/gradlew",
repository / "tools/validate_json.py",
repository / "tools/validate_import_contract.py",
):
if not path.exists():
raise HarnessError(f"prérequis absent: {path}", DEPENDENCY_MISSING)
def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str], Path]]:
python = sys.executable
gradle = str(repository / "android/gradlew")
android = repository / "android"
smoke = [
(
"sync-gap-characterization",
[python, str(repository / "tests/test_sync_gap_characterization.py")],
repository,
),
(
"android-environment-smoke",
[
gradle,
"--no-daemon",
"--rerun-tasks",
":app:testDebugUnitTest",
"--tests",
ENV_ANDROID_TEST,
],
android,
),
]
if suite == "smoke":
return smoke
if suite == "preflight":
return []
return [
("desktop-compile", ["meson", "compile", "-C", "build"], repository),
(
"desktop-tests",
["meson", "test", "-C", "build", "--print-errorlogs"],
repository,
),
(
"desktop-asan-compile",
["meson", "compile", "-C", "build-asan"],
repository,
),
(
"desktop-asan-tests",
["meson", "test", "-C", "build-asan", "--print-errorlogs"],
repository,
),
smoke[0],
("json-validator", [python, str(repository / "tools/validate_json.py")], repository),
(
"import-contract-validator",
[python, str(repository / "tools/validate_import_contract.py")],
repository,
),
(
"android-targeted",
[
gradle,
"--no-daemon",
"--rerun-tasks",
":app:testDebugUnitTest",
"--tests",
TARGET_ANDROID_TEST,
],
android,
),
(
"android-full-and-assemble",
[
gradle,
"--no-daemon",
"--rerun-tasks",
":app:testDebugUnitTest",
":app:assembleDebug",
],
android,
),
]
def write_summary(paths: RunPaths, payload: dict[str, object]) -> None:
destination = paths.reports / "summary.json"
destination.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
destination.chmod(0o600)
def parse_arguments(arguments: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--suite", choices=("preflight", "smoke", "full"), default="full")
parser.add_argument("--jdk", help="JDK 17 home; auto-detected when omitted")
parser.add_argument("--run-parent", type=Path, help="private run parent outside /tmp")
parser.add_argument("--keep-run-dir", action="store_true", help="retain a successful run")
return parser.parse_args(arguments)
def main(arguments: list[str] | None = None) -> int:
options = parse_arguments(arguments if arguments is not None else sys.argv[1:])
repository = Path(__file__).resolve().parents[1]
inherited = os.environ.copy()
active: set[subprocess.Popen[bytes]] = set()
paths: RunPaths | None = None
parent: Path | None = None
try:
required_tools(repository)
jdk = resolve_jdk(options.jdk, inherited)
parent = check_parent(
options.run_parent.resolve()
if options.run_parent
else default_run_parent(inherited)
)
paths = create_run_paths(parent)
original_home = Path(inherited.get("HOME", str(Path.home()))).resolve()
gradle_cache = Path(
inherited.get("GRADLE_USER_HOME", str(original_home / ".gradle"))
).resolve()
if not gradle_cache.is_dir():
raise HarnessError(
f"cache Gradle préprovisionné absent: {gradle_cache}",
DEPENDENCY_MISSING,
)
environment = build_environment(paths, jdk, inherited, gradle_cache)
verify_gradle_jdk(repository, environment, paths.reports, active)
executed: list[str] = []
for label, command, cwd in commands_for_suite(repository, options.suite):
print(f"RUN {label}: {command_label(command)}", flush=True)
run_command(
command,
cwd=cwd,
environment=environment,
log_path=paths.reports / f"{label}.log",
active=active,
)
executed.append(label)
android_counts = None
if options.suite == "full":
android_counts = count_android_results(
repository / "android/app/build/test-results/testDebugUnitTest"
)
if android_counts["failures"] or android_counts["errors"]:
raise HarnessError(
f"résultats Android en échec: {android_counts}", TEST_FAILURE
)
summary: dict[str, object] = {
"status": "success",
"suite": options.suite,
"run_root": str(paths.root),
"jdk_home": str(jdk),
"java_major": 17,
"gradle_user_home": str(gradle_cache),
"isolated_paths": {
"home": str(paths.home),
"data": str(paths.data),
"config": str(paths.config),
"cache": str(paths.cache),
"runtime": str(paths.runtime),
"tmp": str(paths.native_tmp),
"exchange": str(paths.exchange),
"databases": str(paths.databases),
},
"commands": executed,
"android": android_counts,
}
write_summary(paths, summary)
print(json.dumps(summary, indent=2, sort_keys=True))
if options.keep_run_dir:
print(f"RUN_RETAINED={paths.root}")
else:
safe_cleanup(paths.root, parent)
print(f"RUN_CLEANED={paths.root}")
return SUCCESS
except KeyboardInterrupt:
stop_children(active)
if paths:
print(f"INTERRUPTED RUN_RETAINED={paths.root}", file=sys.stderr)
return INTERRUPTED
except HarnessError as error:
stop_children(active)
if paths:
try:
write_summary(
paths,
{"status": "failed", "exit_code": error.exit_code, "error": str(error)},
)
except OSError:
pass
print(f"RUN_RETAINED={paths.root}", file=sys.stderr)
print(f"ERROR[{error.exit_code}] {error}", file=sys.stderr)
return error.exit_code
if __name__ == "__main__":
sys.exit(main())