544 lines
18 KiB
Python
Executable file
544 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 []
|
|
commands = [
|
|
("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,
|
|
),
|
|
]
|
|
browser_python = os.environ.get("TRAINLOG_WEB_E2E_PYTHON")
|
|
if browser_python:
|
|
commands.append(("web-sync-browser", [browser_python,
|
|
str(repository / "tests/test_web_sync_browser.py"), "-v"], repository))
|
|
return commands
|
|
|
|
|
|
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())
|