mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 07:14:08 +09:00
671 lines
24 KiB
Python
671 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Run evidence-producing ServerKit checks across distro containers.
|
|
|
|
This is a discovery runner, not a support-claim generator. A passing plain
|
|
container proves only the named mode:
|
|
|
|
* quick: shell syntax and source-level installer/update test suites
|
|
* provision: the real distro repositories can supply a supported Python,
|
|
virtualenv, pip, ssl, sqlite3, ctypes, and native dependency builds through
|
|
install.sh (Ubuntu 26.04 also verifies the full backend requirements)
|
|
|
|
It does not prove systemd, nginx, Docker-in-Docker, firewall behavior, a full
|
|
ServerKit install, a distro-specific kernel, or hardware. The generated report
|
|
keeps exact, proxy, untestable, failed, and infrastructure-error states apart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
REPO_ROOT = SCRIPT_DIR.parents[1]
|
|
CATALOG_PATH = REPO_ROOT / "backend" / "app" / "data" / "distro-catalog.json"
|
|
DEFAULT_OUTPUT_ROOT = SCRIPT_DIR / "output" / "distro-compatibility"
|
|
MODES = ("quick", "provision")
|
|
|
|
# The suites run inside MINIMAL base images, so the harness must guarantee the
|
|
# tools the source-level suites legitimately assume a server has: bash, plus
|
|
# find (test_update's backup-retention assertions) and awk (test_install /
|
|
# test_cli function-body extraction). Amazon Linux 2023 ships no findutils,
|
|
# openSUSE Tumbleweed's minimal image has neither, and the SLES BCI has no awk —
|
|
# without this bootstrap those are harness gaps, not product failures.
|
|
BOOTSTRAP_BASH = r"""
|
|
need=""
|
|
command -v bash >/dev/null 2>&1 || need="$need bash"
|
|
command -v find >/dev/null 2>&1 || need="$need find"
|
|
command -v awk >/dev/null 2>&1 || need="$need awk"
|
|
if [ -n "$need" ]; then
|
|
pkgs=""
|
|
for tool in $need; do
|
|
if command -v emerge >/dev/null 2>&1; then
|
|
case "$tool" in
|
|
bash) pkgs="$pkgs app-shells/bash" ;;
|
|
find) pkgs="$pkgs sys-apps/findutils" ;;
|
|
awk) pkgs="$pkgs sys-apps/gawk" ;;
|
|
esac
|
|
else
|
|
case "$tool" in
|
|
bash) pkgs="$pkgs bash" ;;
|
|
find) pkgs="$pkgs findutils" ;;
|
|
awk) pkgs="$pkgs gawk" ;;
|
|
esac
|
|
fi
|
|
done
|
|
{ command -v apt-get >/dev/null 2>&1 && apt-get update -qq && apt-get install -y -qq $pkgs; } ||
|
|
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q $pkgs; } ||
|
|
{ command -v yum >/dev/null 2>&1 && yum install -y -q $pkgs; } ||
|
|
{ command -v zypper >/dev/null 2>&1 && zypper --non-interactive install $pkgs; } ||
|
|
{ command -v pacman >/dev/null 2>&1 && pacman -Sy --noconfirm $pkgs; } ||
|
|
{ command -v apk >/dev/null 2>&1 && apk add --no-cache $pkgs; } ||
|
|
{ command -v emerge >/dev/null 2>&1 && emerge --oneshot $pkgs; } || true
|
|
fi
|
|
for tool in bash find awk; do
|
|
command -v "$tool" >/dev/null 2>&1 || {
|
|
echo "COMPAT_HARNESS_ERROR=no $tool available" >&2
|
|
exit 127
|
|
}
|
|
done
|
|
""".strip()
|
|
|
|
MODE_COMMANDS = {
|
|
"quick": "exec bash /src/scripts/test/run-distro-quick.sh",
|
|
"provision": "exec bash /src/scripts/test/test_provision_python.sh",
|
|
}
|
|
MODE_TIMEOUTS = {"quick": 900, "provision": 1500}
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]:
|
|
catalog = json.loads(path.read_text(encoding="utf-8"))
|
|
targets = catalog.get("targets")
|
|
if catalog.get("schema_version") != 1 or not isinstance(targets, list):
|
|
raise ValueError("unsupported or malformed distro catalog")
|
|
if len(targets) != 25:
|
|
raise ValueError(f"catalog must describe exactly 25 targets, found {len(targets)}")
|
|
|
|
target_ids: set[str] = set()
|
|
probe_keys: set[str] = set()
|
|
for target in targets:
|
|
target_id = target.get("id")
|
|
if not target_id or target_id in target_ids:
|
|
raise ValueError(f"missing or duplicate target id: {target_id!r}")
|
|
target_ids.add(target_id)
|
|
for probe in target.get("probes", []):
|
|
key = probe.get("key")
|
|
if not key or key in probe_keys:
|
|
raise ValueError(f"missing or duplicate probe key: {key!r}")
|
|
if probe.get("fidelity") not in ("exact", "proxy"):
|
|
raise ValueError(f"probe {key} has invalid fidelity")
|
|
if not probe.get("image"):
|
|
raise ValueError(f"probe {key} has no container image")
|
|
probe_keys.add(key)
|
|
|
|
for probe in catalog.get("legacy_probes", []):
|
|
key = probe.get("key")
|
|
if not key or key in probe_keys:
|
|
raise ValueError(f"missing or duplicate legacy probe key: {key!r}")
|
|
if probe.get("target_id") not in target_ids:
|
|
raise ValueError(f"legacy probe {key} references an unknown target")
|
|
probe_keys.add(key)
|
|
|
|
for target in targets:
|
|
missing = set(target.get("proxy_probe_keys", [])) - probe_keys
|
|
if missing:
|
|
raise ValueError(
|
|
f"target {target['id']} references unknown proxy probes: {sorted(missing)}"
|
|
)
|
|
return catalog
|
|
|
|
|
|
def flatten_probes(catalog: dict[str, Any], include_legacy: bool) -> list[dict[str, Any]]:
|
|
probes: list[dict[str, Any]] = []
|
|
for target in catalog["targets"]:
|
|
for position, raw_probe in enumerate(target.get("probes", [])):
|
|
probe = dict(raw_probe)
|
|
probe.update(
|
|
target_id=target["id"],
|
|
target_label=target["label"],
|
|
family=target["family"],
|
|
published_status=target["published_status"],
|
|
catalog_position=len(probes),
|
|
target_probe_position=position,
|
|
)
|
|
probes.append(probe)
|
|
if include_legacy:
|
|
targets = {target["id"]: target for target in catalog["targets"]}
|
|
for raw_probe in catalog.get("legacy_probes", []):
|
|
probe = dict(raw_probe)
|
|
target = targets[probe["target_id"]]
|
|
probe.update(
|
|
target_label=target["label"],
|
|
family=probe.get("family", target["family"]),
|
|
published_status="legacy",
|
|
catalog_position=len(probes),
|
|
target_probe_position=-1,
|
|
)
|
|
probes.append(probe)
|
|
return probes
|
|
|
|
|
|
def normalize_only(values: list[str]) -> set[str]:
|
|
return {
|
|
value.strip()
|
|
for item in values
|
|
for value in item.split(",")
|
|
if value.strip()
|
|
}
|
|
|
|
|
|
def safe_slug(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9_.-]+", "-", value.lower()).strip("-.")
|
|
|
|
|
|
def run_logged(
|
|
args: list[str], log_path: Path, timeout_s: int, *, append: bool = False
|
|
) -> tuple[int | None, float, str]:
|
|
started = time.monotonic()
|
|
mode = "a" if append else "w"
|
|
with log_path.open(mode, encoding="utf-8", errors="replace") as log:
|
|
if append:
|
|
log.write("\n")
|
|
log.write("$ " + " ".join(args) + "\n\n")
|
|
log.flush()
|
|
try:
|
|
proc = subprocess.run(
|
|
args,
|
|
cwd=REPO_ROOT,
|
|
stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
timeout=timeout_s,
|
|
check=False,
|
|
)
|
|
return proc.returncode, time.monotonic() - started, ""
|
|
except subprocess.TimeoutExpired:
|
|
log.write(f"\nCOMPAT_HARNESS_TIMEOUT={timeout_s}s\n")
|
|
return None, time.monotonic() - started, f"timed out after {timeout_s}s"
|
|
except OSError as exc:
|
|
log.write(f"\nCOMPAT_HARNESS_OS_ERROR={exc}\n")
|
|
return None, time.monotonic() - started, str(exc)
|
|
|
|
|
|
def docker_output(args: list[str], timeout_s: int = 30) -> str:
|
|
try:
|
|
proc = subprocess.run(
|
|
["docker", *args],
|
|
cwd=REPO_ROOT,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
timeout=timeout_s,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return ""
|
|
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
|
|
|
|
def inspect_image(image: str) -> dict[str, str]:
|
|
image_id = docker_output(["image", "inspect", "--format", "{{.Id}}", image])
|
|
identity_cmd = (
|
|
". /etc/os-release 2>/dev/null || true; "
|
|
"printf '%s\\t%s\\t%s\\n' \"${ID:-unknown}\" "
|
|
"\"${VERSION_ID:-unknown}\" \"${PRETTY_NAME:-unknown}\""
|
|
)
|
|
identity = docker_output(["run", "--rm", image, "sh", "-c", identity_cmd], 60)
|
|
parts = identity.split("\t", 2)
|
|
if len(parts) != 3:
|
|
parts = ["unknown", "unknown", identity or "unknown"]
|
|
return {
|
|
"image_id": image_id,
|
|
"os_id": parts[0],
|
|
"version_id": parts[1],
|
|
"pretty_name": parts[2],
|
|
}
|
|
|
|
|
|
def failure_excerpt(log_path: Path, mode: str) -> str:
|
|
"""Pull the first useful cause from a failed check without parsing pass counts."""
|
|
text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
useful: list[str] = []
|
|
needles = ("✘", "command not found", "No supported package manager")
|
|
for raw_line in text.splitlines():
|
|
line = " ".join(raw_line.strip().split())
|
|
harness_error = line.startswith("COMPAT_HARNESS_")
|
|
if line and (harness_error or any(needle.lower() in line.lower() for needle in needles)):
|
|
if line not in useful:
|
|
useful.append(line)
|
|
if len(useful) == 3:
|
|
break
|
|
if useful:
|
|
excerpt = "; ".join(useful)
|
|
return excerpt[:500]
|
|
if mode == "provision" and " distro:" not in text:
|
|
return "installer exited during OS/package-manager detection before the provision assertions"
|
|
return "see log for the failing assertion"
|
|
|
|
|
|
def result_for_pull_failure(
|
|
probe: dict[str, Any], mode: str, exit_code: int | None, duration_s: float, detail: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
**{key: probe[key] for key in (
|
|
"key", "label", "target_id", "target_label", "family", "image", "fidelity"
|
|
)},
|
|
"mode": mode,
|
|
"status": "infra_error",
|
|
"exit_code": exit_code,
|
|
"duration_s": round(duration_s, 1),
|
|
"detail": detail,
|
|
"log": f"{probe['key']}/pull.log",
|
|
"identity": {},
|
|
"catalog_position": probe["catalog_position"],
|
|
}
|
|
|
|
|
|
def run_probe(
|
|
probe: dict[str, Any],
|
|
modes: tuple[str, ...],
|
|
run_dir: Path,
|
|
no_pull: bool,
|
|
remove_images: bool,
|
|
) -> list[dict[str, Any]]:
|
|
probe_dir = run_dir / probe["key"]
|
|
probe_dir.mkdir(parents=True, exist_ok=True)
|
|
image = probe["image"]
|
|
|
|
if not no_pull:
|
|
pull_rc, pull_duration, pull_error = run_logged(
|
|
["docker", "pull", image], probe_dir / "pull.log", timeout_s=900
|
|
)
|
|
if pull_rc != 0:
|
|
if pull_rc is None:
|
|
detail = pull_error or "image pull did not return an exit code"
|
|
else:
|
|
detail = f"docker pull failed with exit {pull_rc}"
|
|
return [
|
|
result_for_pull_failure(probe, mode, pull_rc, pull_duration, detail)
|
|
for mode in modes
|
|
]
|
|
else:
|
|
(probe_dir / "pull.log").write_text(
|
|
"Pull skipped by --no-pull; Docker may use a cached image.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
identity = inspect_image(image)
|
|
results: list[dict[str, Any]] = []
|
|
mount_source = REPO_ROOT.as_posix()
|
|
for mode in modes:
|
|
container_name = safe_slug(
|
|
f"sk-compat-{probe['key']}-{mode}-{uuid.uuid4().hex[:8]}"
|
|
)[:120]
|
|
script = BOOTSTRAP_BASH + "\n" + MODE_COMMANDS[mode]
|
|
command = [
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--name",
|
|
container_name,
|
|
"--mount",
|
|
f"type=bind,source={mount_source},target=/src,readonly",
|
|
"--workdir",
|
|
"/src",
|
|
image,
|
|
"sh",
|
|
"-c",
|
|
script,
|
|
]
|
|
log_path = probe_dir / f"{mode}.log"
|
|
rc, duration, error = run_logged(
|
|
command, log_path, timeout_s=MODE_TIMEOUTS[mode]
|
|
)
|
|
if rc is None:
|
|
status = "infra_error"
|
|
detail = error or "docker run did not return an exit code"
|
|
docker_output(["rm", "-f", container_name])
|
|
elif rc == 0:
|
|
status = "passed"
|
|
detail = "exit 0"
|
|
elif rc == 125:
|
|
status = "infra_error"
|
|
detail = "docker could not start the test container (exit 125)"
|
|
else:
|
|
status = "failed"
|
|
detail = f"test exited {rc}: {failure_excerpt(log_path, mode)}"
|
|
results.append(
|
|
{
|
|
**{key: probe[key] for key in (
|
|
"key", "label", "target_id", "target_label", "family", "image", "fidelity"
|
|
)},
|
|
"mode": mode,
|
|
"status": status,
|
|
"exit_code": rc,
|
|
"duration_s": round(duration, 1),
|
|
"detail": detail,
|
|
"log": f"{probe['key']}/{mode}.log",
|
|
"identity": identity,
|
|
"catalog_position": probe["catalog_position"],
|
|
}
|
|
)
|
|
|
|
if remove_images:
|
|
docker_output(["image", "rm", image], 120)
|
|
return results
|
|
|
|
|
|
def md_escape(value: Any) -> str:
|
|
return str(value).replace("|", "\\|").replace("\n", " ")
|
|
|
|
|
|
def result_counts(results: list[dict[str, Any]]) -> dict[str, int]:
|
|
return {
|
|
status: sum(result["status"] == status for result in results)
|
|
for status in ("passed", "failed", "infra_error")
|
|
}
|
|
|
|
|
|
def target_evidence(
|
|
target: dict[str, Any], results: list[dict[str, Any]]
|
|
) -> tuple[str, str]:
|
|
own_keys = {probe["key"] for probe in target.get("probes", [])}
|
|
proxy_keys = set(target.get("proxy_probe_keys", []))
|
|
relevant = [result for result in results if result["key"] in own_keys]
|
|
evidence_kind = "exact" if any(
|
|
probe.get("fidelity") == "exact" for probe in target.get("probes", [])
|
|
) else "proxy"
|
|
if not relevant and proxy_keys:
|
|
relevant = [result for result in results if result["key"] in proxy_keys]
|
|
evidence_kind = "base proxy"
|
|
if not relevant:
|
|
if own_keys or proxy_keys:
|
|
return "not selected", "No matching probe was selected in this run."
|
|
return "separate harness", target["windows_strategy"]
|
|
|
|
counts = result_counts(relevant)
|
|
total = len(relevant)
|
|
if counts["failed"]:
|
|
state = f"{evidence_kind} failing"
|
|
elif counts["infra_error"]:
|
|
state = f"{evidence_kind} infrastructure error"
|
|
elif counts["passed"] == total:
|
|
state = f"{evidence_kind} passed"
|
|
else:
|
|
state = f"{evidence_kind} incomplete"
|
|
detail = (
|
|
f"{counts['passed']}/{total} checks passed; "
|
|
f"{counts['failed']} failed; {counts['infra_error']} infrastructure errors."
|
|
)
|
|
if evidence_kind != "exact":
|
|
detail += " This is not exact distro/full-install proof."
|
|
return state, detail
|
|
|
|
|
|
def write_markdown(
|
|
path: Path,
|
|
catalog: dict[str, Any],
|
|
probes: list[dict[str, Any]],
|
|
results: list[dict[str, Any]],
|
|
metadata: dict[str, Any],
|
|
) -> None:
|
|
counts = result_counts(results)
|
|
exact_probes = sum(probe["fidelity"] == "exact" for probe in probes)
|
|
proxy_probes = sum(probe["fidelity"] == "proxy" for probe in probes)
|
|
lines = [
|
|
"# ServerKit distro compatibility discovery",
|
|
"",
|
|
f"Generated: `{metadata['finished_at']}` ",
|
|
f"Host: `{md_escape(metadata['host'])}` ",
|
|
f"Docker: `{md_escape(metadata['docker_version'] or 'unavailable')}` ",
|
|
f"Modes: `{', '.join(metadata['modes'])}`",
|
|
"",
|
|
"## Result summary",
|
|
"",
|
|
f"- Published targets cataloged: **{len(catalog['targets'])}/25**",
|
|
f"- Concrete images selected: **{len(probes)}** "
|
|
f"({exact_probes} exact, {proxy_probes} vendor userland proxies)",
|
|
f"- Checks: **{len(results)}** — {counts['passed']} passed, "
|
|
f"{counts['failed']} failed, {counts['infra_error']} infrastructure errors",
|
|
"",
|
|
"> A quick pass proves shell syntax and source-level suites only. A provision pass proves",
|
|
"> that install.sh obtained a usable supported Python from real repositories. Neither is a",
|
|
"> full ServerKit install, systemd, nginx, firewall, kernel, hardware, LXC, or hypervisor pass.",
|
|
"",
|
|
"## All 25 published targets",
|
|
"",
|
|
"| Target | Published claim | Container evidence | Detail | Windows test path |",
|
|
"|---|---:|---|---|---|",
|
|
]
|
|
for target in catalog["targets"]:
|
|
state, detail = target_evidence(target, results)
|
|
lines.append(
|
|
"| "
|
|
+ " | ".join(
|
|
md_escape(value)
|
|
for value in (
|
|
f"{target['label']} {target['versions']}",
|
|
target["published_status"],
|
|
state,
|
|
detail,
|
|
target["windows_strategy"],
|
|
)
|
|
)
|
|
+ " |"
|
|
)
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Concrete probe results",
|
|
"",
|
|
"| Probe | Fidelity | Pulled identity | Mode | Result | Duration | Log |",
|
|
"|---|---|---|---|---:|---:|---|",
|
|
]
|
|
)
|
|
for result in sorted(results, key=lambda item: (item["catalog_position"], MODES.index(item["mode"]))):
|
|
identity = result.get("identity", {})
|
|
pretty = identity.get("pretty_name") or "identity unavailable"
|
|
lines.append(
|
|
"| "
|
|
+ " | ".join(
|
|
(
|
|
md_escape(f"{result['label']} (`{result['image']}`)"),
|
|
md_escape(result["fidelity"]),
|
|
md_escape(pretty),
|
|
md_escape(result["mode"]),
|
|
md_escape(result["status"]),
|
|
md_escape(f"{result['duration_s']:.1f}s"),
|
|
f"[log]({result['log']})",
|
|
)
|
|
)
|
|
+ " |"
|
|
)
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Interpretation",
|
|
"",
|
|
"- `failed` means the image ran and the ServerKit check returned non-zero. It is product evidence, not hidden as an expected failure.",
|
|
"- `infra_error` means the image could not be pulled/run or the harness timed out; it is not evidence that ServerKit failed.",
|
|
"- `base proxy` means only a related base distro ran. It must never be promoted to an exact distro support result.",
|
|
"- Full support numbers require the existing VM/systemd harnesses and special hosts listed in the target table.",
|
|
"",
|
|
]
|
|
)
|
|
failed = [result for result in results if result["status"] == "failed"]
|
|
if failed:
|
|
lines.extend(
|
|
[
|
|
"## Failed-check details",
|
|
"",
|
|
"| Probe | Mode | First useful evidence |",
|
|
"|---|---|---|",
|
|
]
|
|
)
|
|
for result in failed:
|
|
lines.append(
|
|
"| "
|
|
+ " | ".join(
|
|
md_escape(value)
|
|
for value in (result["label"], result["mode"], result["detail"])
|
|
)
|
|
+ " |"
|
|
)
|
|
lines.append("")
|
|
path.write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--mode", choices=("quick", "provision", "both"), default="both")
|
|
parser.add_argument(
|
|
"--only",
|
|
nargs="*",
|
|
default=[],
|
|
metavar="KEY",
|
|
help="probe keys, separated by spaces or commas",
|
|
)
|
|
parser.add_argument("--jobs", type=int, default=4, help="parallel image workers")
|
|
parser.add_argument("--include-legacy", action="store_true")
|
|
parser.add_argument("--no-pull", action="store_true", help="use cached images when present")
|
|
parser.add_argument("--remove-images", action="store_true", help="remove tested images after each probe")
|
|
parser.add_argument("--strict", action="store_true", help="exit non-zero for failed/infra checks")
|
|
parser.add_argument("--list", action="store_true", help="validate and list the catalog without Docker")
|
|
parser.add_argument("--output", type=Path, help="report directory (default: timestamped output)")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
catalog = load_catalog()
|
|
all_probes = flatten_probes(catalog, args.include_legacy)
|
|
selected_keys = normalize_only(args.only)
|
|
known_keys = {probe["key"] for probe in all_probes}
|
|
unknown = selected_keys - known_keys
|
|
if unknown:
|
|
print(f"Unknown probe key(s): {', '.join(sorted(unknown))}", file=sys.stderr)
|
|
return 2
|
|
probes = [
|
|
probe for probe in all_probes if not selected_keys or probe["key"] in selected_keys
|
|
]
|
|
|
|
if args.list:
|
|
for target in catalog["targets"]:
|
|
keys = ", ".join(probe["key"] for probe in target.get("probes", [])) or "separate harness"
|
|
print(
|
|
f"{target['id']:<22} {target['published_status']:<10} "
|
|
f"{target['label']} {target['versions']}: {keys}"
|
|
)
|
|
print(f"\n25 targets; {len(all_probes)} runnable image probes")
|
|
return 0
|
|
|
|
if shutil.which("docker") is None:
|
|
print("Docker CLI is not on PATH", file=sys.stderr)
|
|
return 2
|
|
docker_version = docker_output(["version", "--format", "{{.Server.Version}} {{.Server.Os}}/{{.Server.Arch}}"])
|
|
if not docker_version:
|
|
print("Docker daemon is unavailable", file=sys.stderr)
|
|
return 2
|
|
|
|
modes = MODES if args.mode == "both" else (args.mode,)
|
|
run_stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
run_dir = (args.output or (DEFAULT_OUTPUT_ROOT / run_stamp)).resolve()
|
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
metadata = {
|
|
"started_at": utc_now(),
|
|
"finished_at": None,
|
|
"host": f"{platform.system()} {platform.release()} {platform.machine()}",
|
|
"python": sys.version.split()[0],
|
|
"docker_version": docker_version,
|
|
"modes": list(modes),
|
|
"selected_probe_keys": [probe["key"] for probe in probes],
|
|
"jobs": max(1, args.jobs),
|
|
}
|
|
print(
|
|
f"ServerKit distro discovery: {len(probes)} images x {len(modes)} mode(s), "
|
|
f"{max(1, args.jobs)} workers",
|
|
flush=True,
|
|
)
|
|
print(f"Output: {run_dir}", flush=True)
|
|
|
|
results: list[dict[str, Any]] = []
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
|
future_to_probe = {
|
|
pool.submit(
|
|
run_probe,
|
|
probe,
|
|
modes,
|
|
run_dir,
|
|
args.no_pull,
|
|
args.remove_images,
|
|
): probe
|
|
for probe in probes
|
|
}
|
|
for future in concurrent.futures.as_completed(future_to_probe):
|
|
probe = future_to_probe[future]
|
|
try:
|
|
probe_results = future.result()
|
|
except Exception as exc: # keep one harness bug from erasing the run
|
|
probe_results = [
|
|
result_for_pull_failure(
|
|
probe, mode, None, 0.0, f"unhandled harness error: {exc}"
|
|
)
|
|
for mode in modes
|
|
]
|
|
results.extend(probe_results)
|
|
summary = ", ".join(
|
|
f"{result['mode']}={result['status']}" for result in probe_results
|
|
)
|
|
print(
|
|
f"[{len(results):>3}/{len(probes) * len(modes)}] "
|
|
f"{probe['label']}: {summary}",
|
|
flush=True,
|
|
)
|
|
|
|
results.sort(key=lambda item: (item["catalog_position"], MODES.index(item["mode"])))
|
|
metadata["finished_at"] = utc_now()
|
|
payload = {
|
|
"schema_version": 1,
|
|
"metadata": metadata,
|
|
"summary": result_counts(results),
|
|
"targets": catalog["targets"],
|
|
"results": results,
|
|
}
|
|
(run_dir / "report.json").write_text(
|
|
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
write_markdown(run_dir / "report.md", catalog, probes, results, metadata)
|
|
|
|
counts = payload["summary"]
|
|
print(
|
|
f"Done: {counts['passed']} passed, {counts['failed']} failed, "
|
|
f"{counts['infra_error']} infrastructure errors",
|
|
flush=True,
|
|
)
|
|
print(f"Report: {run_dir / 'report.md'}", flush=True)
|
|
if args.strict and (counts["failed"] or counts["infra_error"]):
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|