222 lines
7.4 KiB
Python
222 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Mirror release debug-store artefacts into the Offline Kit staging tree.
|
||
|
||
This helper copies the release `debug/` directory (including `.build-id/`,
|
||
`debug-manifest.json`, and the `.sha256` companion) into the Offline Kit
|
||
output directory and verifies the manifest hashes after the copy. A summary
|
||
document is written under `metadata/debug-store.json` so packaging jobs can
|
||
surface the available build-ids and validation status.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import json
|
||
import pathlib
|
||
import shutil
|
||
import sys
|
||
from typing import Iterable, Tuple
|
||
|
||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||
|
||
|
||
def compute_sha256(path: pathlib.Path) -> str:
|
||
import hashlib
|
||
|
||
sha = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
sha.update(chunk)
|
||
return sha.hexdigest()
|
||
|
||
|
||
def load_manifest(manifest_path: pathlib.Path) -> dict:
|
||
with manifest_path.open("r", encoding="utf-8") as handle:
|
||
return json.load(handle)
|
||
|
||
|
||
def parse_manifest_sha(sha_path: pathlib.Path) -> str | None:
|
||
if not sha_path.exists():
|
||
return None
|
||
text = sha_path.read_text(encoding="utf-8").strip()
|
||
if not text:
|
||
return None
|
||
# Allow either "<sha>" or "<sha> filename" formats.
|
||
return text.split()[0]
|
||
|
||
|
||
def iter_debug_files(base_dir: pathlib.Path) -> Iterable[pathlib.Path]:
|
||
for path in base_dir.rglob("*"):
|
||
if path.is_file():
|
||
yield path
|
||
|
||
|
||
def copy_debug_store(source_root: pathlib.Path, target_root: pathlib.Path, *, dry_run: bool) -> None:
|
||
if dry_run:
|
||
print(f"[dry-run] Would copy '{source_root}' -> '{target_root}'")
|
||
return
|
||
|
||
if target_root.exists():
|
||
shutil.rmtree(target_root)
|
||
shutil.copytree(source_root, target_root)
|
||
|
||
|
||
def verify_debug_store(manifest: dict, offline_root: pathlib.Path) -> Tuple[int, int]:
|
||
"""Return (verified_count, total_entries)."""
|
||
|
||
artifacts = manifest.get("artifacts", [])
|
||
verified = 0
|
||
for entry in artifacts:
|
||
debug_path = entry.get("debugPath")
|
||
expected_sha = entry.get("sha256")
|
||
expected_size = entry.get("size")
|
||
|
||
if not debug_path or not expected_sha:
|
||
continue
|
||
|
||
relative = pathlib.PurePosixPath(debug_path)
|
||
resolved = (offline_root.parent / relative).resolve()
|
||
|
||
if not resolved.exists():
|
||
raise FileNotFoundError(f"Debug artefact missing after mirror: {relative}")
|
||
|
||
actual_sha = compute_sha256(resolved)
|
||
if actual_sha != expected_sha:
|
||
raise ValueError(
|
||
f"Digest mismatch for {relative}: expected {expected_sha}, found {actual_sha}"
|
||
)
|
||
|
||
if expected_size is not None:
|
||
actual_size = resolved.stat().st_size
|
||
if actual_size != expected_size:
|
||
raise ValueError(
|
||
f"Size mismatch for {relative}: expected {expected_size}, found {actual_size}"
|
||
)
|
||
|
||
verified += 1
|
||
|
||
return verified, len(artifacts)
|
||
|
||
|
||
def summarize_store(manifest: dict, manifest_sha: str | None, offline_root: pathlib.Path, summary_path: pathlib.Path) -> None:
|
||
debug_files = [
|
||
path
|
||
for path in iter_debug_files(offline_root)
|
||
if path.suffix == ".debug"
|
||
]
|
||
|
||
total_size = sum(path.stat().st_size for path in debug_files)
|
||
build_ids = sorted(
|
||
{entry.get("buildId") for entry in manifest.get("artifacts", []) if entry.get("buildId")}
|
||
)
|
||
|
||
summary = {
|
||
"generatedAt": dt.datetime.now(tz=dt.timezone.utc)
|
||
.replace(microsecond=0)
|
||
.isoformat()
|
||
.replace("+00:00", "Z"),
|
||
"manifestGeneratedAt": manifest.get("generatedAt"),
|
||
"manifestSha256": manifest_sha,
|
||
"platforms": manifest.get("platforms")
|
||
or sorted({entry.get("platform") for entry in manifest.get("artifacts", []) if entry.get("platform")}),
|
||
"artifactCount": len(manifest.get("artifacts", [])),
|
||
"buildIds": {
|
||
"total": len(build_ids),
|
||
"samples": build_ids[:10],
|
||
},
|
||
"debugFiles": {
|
||
"count": len(debug_files),
|
||
"totalSizeBytes": total_size,
|
||
},
|
||
}
|
||
|
||
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with summary_path.open("w", encoding="utf-8") as handle:
|
||
json.dump(summary, handle, indent=2)
|
||
handle.write("\n")
|
||
|
||
|
||
def resolve_release_debug_dir(base: pathlib.Path) -> pathlib.Path:
|
||
debug_dir = base / "debug"
|
||
if debug_dir.exists():
|
||
return debug_dir
|
||
|
||
# Allow specifying the channel directory directly (e.g. out/release/stable)
|
||
if base.name == "debug":
|
||
return base
|
||
|
||
raise FileNotFoundError(f"Debug directory not found under '{base}'")
|
||
|
||
|
||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--release-dir",
|
||
type=pathlib.Path,
|
||
default=REPO_ROOT / "out" / "release",
|
||
help="Release output directory containing the debug store (default: %(default)s)",
|
||
)
|
||
parser.add_argument(
|
||
"--offline-kit-dir",
|
||
type=pathlib.Path,
|
||
default=REPO_ROOT / "out" / "offline-kit",
|
||
help="Offline Kit staging directory (default: %(default)s)",
|
||
)
|
||
parser.add_argument(
|
||
"--verify-only",
|
||
action="store_true",
|
||
help="Skip copying and only verify the existing offline kit debug store",
|
||
)
|
||
parser.add_argument(
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Print actions without copying files",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
|
||
try:
|
||
source_debug = resolve_release_debug_dir(args.release_dir.resolve())
|
||
except FileNotFoundError as exc:
|
||
print(f"error: {exc}", file=sys.stderr)
|
||
return 2
|
||
|
||
target_root = (args.offline_kit_dir / "debug").resolve()
|
||
|
||
if not args.verify_only:
|
||
copy_debug_store(source_debug, target_root, dry_run=args.dry_run)
|
||
if args.dry_run:
|
||
return 0
|
||
|
||
manifest_path = target_root / "debug-manifest.json"
|
||
if not manifest_path.exists():
|
||
print(f"error: offline kit manifest missing at {manifest_path}", file=sys.stderr)
|
||
return 3
|
||
|
||
manifest = load_manifest(manifest_path)
|
||
manifest_sha_path = manifest_path.with_suffix(manifest_path.suffix + ".sha256")
|
||
recorded_sha = parse_manifest_sha(manifest_sha_path)
|
||
recomputed_sha = compute_sha256(manifest_path)
|
||
if recorded_sha and recorded_sha != recomputed_sha:
|
||
print(
|
||
f"warning: manifest SHA mismatch (recorded {recorded_sha}, recomputed {recomputed_sha}); updating checksum",
|
||
file=sys.stderr,
|
||
)
|
||
manifest_sha_path.write_text(f"{recomputed_sha} {manifest_path.name}\n", encoding="utf-8")
|
||
|
||
verified, total = verify_debug_store(manifest, target_root)
|
||
print(f"✔ verified {verified}/{total} debug artefacts (manifest SHA {recomputed_sha})")
|
||
|
||
summary_path = args.offline_kit_dir / "metadata" / "debug-store.json"
|
||
summarize_store(manifest, recomputed_sha, target_root, summary_path)
|
||
print(f"ℹ summary written to {summary_path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|