blob: 119d27371bb009875a91745b560aafcb09f22669 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Exact historical build reuse; a receipt never replaces current validation."""
import argparse
import datetime
import hashlib
import json
import pathlib
import platform
import re
import shutil
import stat
import tempfile
import versioning
SCHEMA = 2
def digest_json(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def safe_path(path):
path = pathlib.Path(path).absolute()
if any(parent.is_symlink() for parent in (path, *path.parents)):
raise ValueError(f"symlink is not a cache input: {path}")
return path
def snapshot(path):
"""Hash names, types and bytes, rejecting links and special files."""
path = safe_path(path)
if not path.exists():
return [["", "missing"]]
result = []
for item in [path, *sorted(path.rglob("*"))] if path.is_dir() else [path]:
mode = item.lstat().st_mode
name = item.relative_to(path).as_posix()
if stat.S_ISDIR(mode):
result.append([name, "directory"])
elif stat.S_ISREG(mode):
result.append([name, hashlib.sha256(item.read_bytes()).hexdigest()])
else:
raise ValueError(f"unsupported cache input: {item}")
return result
def shared_digest(root):
paths = {*versioning.SHELL_FILES, *versioning.SHELL_DIRS,
*versioning.SHELL_CONTENT_DIRS, "versions.json", "dist", ".htaccess", ".asf.yaml",
".github/actions/setup-site-tools"}
paths.update(p for p in versioning.SHELL_CONTENT if p.endswith("docs/SUMMARY.md"))
paths.update("static/" + p for p in (*versioning.SHELL_STATIC_FILES, *versioning.SHELL_STATIC_DIRS))
paths.update(p.relative_to(root).as_posix() for p in (root / "scripts").glob("*.py")
if not p.name.startswith("test_"))
paths.update(p.relative_to(root).as_posix() for p in (root / "scripts").glob("*.sh"))
return digest_json([[p, snapshot(root / p)] for p in sorted(paths)])
def validate_version_id(version):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", version):
raise ValueError("invalid version identifier")
def version_directory(cache_dir, version):
validate_version_id(version)
return safe_path(pathlib.Path(cache_dir) / version)
def reusable(cache_dir, entry):
if not entry["archived"]:
return False
try:
directory = version_directory(cache_dir, entry["id"])
receipt_path = safe_path(directory / "receipt.json")
receipt = json.loads(receipt_path.read_text())
artifact = safe_path(directory / "artifact")
return (artifact.is_dir() and (artifact / ".version.json").is_file()
and receipt["schemaVersion"] == SCHEMA
and receipt["buildKey"] == entry["buildKey"]
and receipt["treeDigest"] == digest_json(snapshot(artifact)))
except (OSError, ValueError, KeyError, TypeError):
return False
def make_plan(root, manifest, cache_dir, *, site_origin, historical_origin,
hugo_version, go_version, webp_version, year=None):
common = {"schemaVersion": SCHEMA, "shared": shared_digest(root),
"siteOrigin": site_origin, "historicalOrigin": historical_origin,
"hugo": hugo_version, "go": go_version, "webp": webp_version,
"python": platform.python_version(), "system": platform.system(),
"machine": platform.machine(), "year": year or datetime.datetime.now(datetime.timezone.utc).year}
entries = []
for source in manifest.get("include", manifest["versions"]):
entry = dict(source)
validate_version_id(entry["id"])
entry["buildKey"] = digest_json({"common": common, "version": source,
"repository": manifest["repository"]})
entry["reusable"] = reusable(cache_dir, entry)
entries.append(entry)
# A compatible bundle can supply unchanged versions even when one archived
# source SHA changes. This prefix only schedules transport; exact receipts
# and current validation still decide whether each version is reusable.
cache_prefix = "history-v2-" + digest_json(common) + "-"
return {"schemaVersion": SCHEMA, "cachePrefix": cache_prefix, "cacheKey": cache_prefix + digest_json(
[[e["id"], e["buildKey"]] for e in entries if e["archived"]]),
"versions": entries, "include": [e for e in entries if not e["reusable"]]}
def build_groups(manifest, cache_hit):
"""Bound cold jobs; a compatible bundle keeps partial hits in one job."""
selected = [entry["id"] for entry in manifest.get("include", manifest["versions"])]
for version in selected:
validate_version_id(version)
if not selected or len(set(selected)) != len(selected):
raise ValueError("selected versions must be nonempty and unique")
count = 1 if cache_hit else min(3, (len(selected) + 1) // 2)
return [{"id": str(index), "versions": selected[index::count]} for index in range(count)]
def verify_expected_plan(actual, expected):
"""Builders may select a subset, but must reproduce every prepared key."""
expected_keys = {entry["id"]: entry["buildKey"] for entry in expected["versions"]}
if len(expected_keys) != len(expected["versions"]):
raise ValueError("expected plan contains duplicate versions")
for entry in actual["versions"]:
if expected_keys.get(entry["id"]) != entry["buildKey"]:
raise ValueError(f"build inputs differ from prepared plan: {entry['id']}")
def record(cache_dir, entry, artifact):
if not entry["archived"]:
raise ValueError("latest artifacts are not reusable")
artifact = safe_path(artifact)
if not artifact.is_dir() or not (artifact / ".version.json").is_file():
raise ValueError("artifact is missing version metadata")
tree_digest = digest_json(snapshot(artifact))
metadata = json.loads((artifact / ".version.json").read_text())
if any(metadata.get(field) != entry.get(field) for field in ("id", "sha")):
raise ValueError("artifact metadata does not match planned version")
destination = version_directory(cache_dir, entry["id"])
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="record-", dir=destination.parent) as temporary:
staging = pathlib.Path(temporary) / "version"
staging.mkdir()
shutil.copytree(artifact, staging / "artifact")
(staging / "receipt.json").write_text(json.dumps({"schemaVersion": SCHEMA,
"buildKey": entry["buildKey"], "treeDigest": tree_digest}))
if destination.exists():
shutil.rmtree(destination)
staging.rename(destination)
def restore(cache_dir, entry, output):
if not reusable(cache_dir, entry):
return False
output = safe_path(output)
if output.exists():
raise ValueError("restore output must not exist")
shutil.copytree(version_directory(cache_dir, entry["id"]) / "artifact", output)
return True
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
plan = commands.add_parser("plan")
plan.add_argument("--resolved-manifest", type=pathlib.Path, required=True)
plan.add_argument("--site-origin", required=True)
plan.add_argument("--historical-origin", required=True)
plan.add_argument("--hugo-version", required=True)
plan.add_argument("--go-version", required=True)
plan.add_argument("--webp-version", default="unspecified")
plan.add_argument("--output", type=pathlib.Path, required=True)
plan.add_argument("--expected-plan", type=pathlib.Path)
groups = commands.add_parser("groups")
groups.add_argument("--resolved-manifest", type=pathlib.Path, required=True)
groups.add_argument("--cache-hit", choices=("true", "false"), required=True)
for name in ("restore", "record"):
command = commands.add_parser(name)
command.add_argument("--plan", type=pathlib.Path, required=True)
command.add_argument("--version", required=True)
command.add_argument("--output" if name == "restore" else "--artifact", type=pathlib.Path, required=True)
command.add_argument("--cache-dir", type=pathlib.Path, required=True)
plan.add_argument("--cache-dir", type=pathlib.Path, required=True)
args = parser.parse_args()
if args.command == "plan":
manifest = versioning.load_resolved_manifest(args.resolved_manifest)
result = make_plan(versioning.ROOT, manifest, args.cache_dir,
site_origin=args.site_origin, historical_origin=args.historical_origin,
hugo_version=args.hugo_version, go_version=args.go_version, webp_version=args.webp_version)
if args.expected_plan:
verify_expected_plan(result, json.loads(args.expected_plan.read_text()))
args.output.write_text(json.dumps(result, indent=2) + "\n")
elif args.command == "groups":
manifest = versioning.load_resolved_manifest(args.resolved_manifest)
print(json.dumps(build_groups(manifest, args.cache_hit == "true")))
else:
plan_data = json.loads(args.plan.read_text())
entry = next(e for e in plan_data["versions"] if e["id"] == args.version)
if args.command == "record":
record(args.cache_dir, entry, args.artifact)
elif not restore(args.cache_dir, entry, args.output):
raise SystemExit(1)
if __name__ == "__main__":
main()