105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a service's image twice from a clean context and compare the results.
|
|
|
|
Distinguishes full image-ID (byte-for-byte, including embedded metadata like
|
|
history timestamps) reproducibility from layer-content (diffID) reproducibility,
|
|
per the WP-200 requirement not to claim byte-identical results without proof.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _run(command: list[str]) -> str:
|
|
completed = subprocess.run( # noqa: S603
|
|
command, cwd=ROOT, capture_output=True, text=True, check=True
|
|
)
|
|
return completed.stdout.strip()
|
|
|
|
|
|
def _build(dockerfile: Path, tag: str, build_args: dict[str, str]) -> None:
|
|
command = [
|
|
"docker",
|
|
"buildx",
|
|
"build",
|
|
"--no-cache",
|
|
"--output",
|
|
"type=docker,rewrite-timestamp=true,unpack=false",
|
|
"-f",
|
|
str(dockerfile),
|
|
"-t",
|
|
tag,
|
|
]
|
|
for name, value in build_args.items():
|
|
command.extend(["--build-arg", f"{name}={value}"])
|
|
command.append(str(ROOT))
|
|
subprocess.run(command, cwd=ROOT, check=True) # noqa: S603
|
|
|
|
|
|
def _image_id(tag: str) -> str:
|
|
return _run(["docker", "inspect", "--format", "{{.Id}}", tag])
|
|
|
|
|
|
def _layer_diff_ids(tag: str) -> list[str]:
|
|
raw = _run(["docker", "inspect", "--format", "{{json .RootFS.Layers}}", tag])
|
|
return list(json.loads(raw))
|
|
|
|
|
|
def compare_two_builds(
|
|
dockerfile: Path, build_args: dict[str, str], tag_a: str, tag_b: str
|
|
) -> dict[str, Any]:
|
|
_build(dockerfile, tag_a, build_args)
|
|
_build(dockerfile, tag_b, build_args)
|
|
image_id_a, image_id_b = _image_id(tag_a), _image_id(tag_b)
|
|
layers_a, layers_b = _layer_diff_ids(tag_a), _layer_diff_ids(tag_b)
|
|
return {
|
|
"dockerfile": str(dockerfile.relative_to(ROOT)),
|
|
"buildArgs": {key: value for key, value in build_args.items() if key != "SOURCE_DATE_EPOCH"}
|
|
| {"SOURCE_DATE_EPOCH": build_args.get("SOURCE_DATE_EPOCH", "")},
|
|
"imageIdA": image_id_a,
|
|
"imageIdB": image_id_b,
|
|
"imageIdMatch": image_id_a == image_id_b,
|
|
"layerDiffIdsA": layers_a,
|
|
"layerDiffIdsB": layers_b,
|
|
"layerDiffIdsMatch": layers_a == layers_b,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dockerfile", type=Path, required=True)
|
|
parser.add_argument("--service-name", required=False)
|
|
parser.add_argument("--git-revision", required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
args.dockerfile = args.dockerfile.resolve()
|
|
epoch = _run(["git", "show", "-s", "--format=%ct", args.git_revision])
|
|
created = _run(["git", "show", "-s", "--format=%cI", args.git_revision])
|
|
build_args = {
|
|
"GIT_REVISION": args.git_revision,
|
|
"SOURCE_DATE_EPOCH": epoch,
|
|
"BUILD_DATE": created,
|
|
}
|
|
if args.service_name:
|
|
build_args["SERVICE_NAME"] = args.service_name
|
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
tag_a = f"local/wp200-repro-a:{stamp}"
|
|
tag_b = f"local/wp200-repro-b:{stamp}"
|
|
report = compare_two_builds(args.dockerfile, build_args, tag_a, tag_b)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|