Private
Public Access
240 lines
11 KiB
Python
Executable File
240 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Stage an accepted Capy-Bearer release for the shared Arty helper."""
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
RECEIPT = "acceptance.json"
|
|
TEST_RECEIPT = "test-receipt.json"
|
|
SHARED_HELPER = pathlib.Path("/root/scripts/arty/release.py")
|
|
HEX = "0123456789abcdef"
|
|
SPEC = importlib.util.spec_from_file_location("capy_runtime_artifacts", ROOT / "deploy/capy-release-receipt.py")
|
|
cohort = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(cohort)
|
|
PAYLOAD = (*(("bin/" + name, mode) for name, mode in cohort.RUNTIME), ("scripts/bearer-cli", 0o755))
|
|
MODULE_ARCHIVE = next(item["path"] for item in json.loads((ROOT / "deploy/arty.json").read_text())["artifacts"] if item.get("runtime_modules") is True)
|
|
|
|
|
|
def payload(root):
|
|
return list(PAYLOAD) + [("bin/" + name, mode) for name, mode in cohort.modules(root / "bin")]
|
|
|
|
|
|
def fail(message):
|
|
raise ValueError(message)
|
|
|
|
|
|
def sha256(path):
|
|
return cohort.digest(path)
|
|
|
|
|
|
def write_module_archive(bundle):
|
|
with tarfile.open(bundle / MODULE_ARCHIVE, "w", format=tarfile.USTAR_FORMAT) as archive:
|
|
for name, mode in [("bin/" + name, mode) for name, mode in cohort.modules(bundle / "bin")]:
|
|
path = regular_file(bundle, name)
|
|
member = archive.gettarinfo(path, arcname=name)
|
|
member.uid = member.gid = member.mtime = 0
|
|
member.uname = member.gname = ""
|
|
member.mode = mode
|
|
with path.open("rb") as stream:
|
|
archive.addfile(member, stream)
|
|
|
|
|
|
def validate_module_archive(bundle, recorded):
|
|
expected = {name for name in recorded if name.startswith("bin/") and cohort.module_name(name[4:])}
|
|
seen = set()
|
|
with tarfile.open(regular_file(bundle, MODULE_ARCHIVE), "r:*") as archive:
|
|
for member in archive:
|
|
if not member.isfile() or member.name not in expected or member.name in seen:
|
|
fail(f"unexpected logging module archive member: {member.name}")
|
|
seen.add(member.name)
|
|
with archive.extractfile(member) as stream:
|
|
if cohort.digest_stream(stream) != recorded[member.name]:
|
|
fail(f"logging module archive does not match the acceptance receipt: {member.name}")
|
|
if seen != expected:
|
|
fail("logging module archive does not name the full payload")
|
|
|
|
|
|
def clean_revision(root):
|
|
try:
|
|
status = subprocess.run(
|
|
["git", "-C", str(root), "status", "--porcelain", "--untracked-files=all"],
|
|
check=True, text=True, capture_output=True, timeout=30,
|
|
)
|
|
revision = subprocess.run(
|
|
["git", "-C", str(root), "rev-parse", "--verify", "HEAD^{commit}"],
|
|
check=True, text=True, capture_output=True, timeout=30,
|
|
).stdout.strip()
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
fail(f"cannot inspect the source revision: {error}")
|
|
if status.stdout:
|
|
fail("the source worktree is not clean")
|
|
if len(revision) != 40 or any(char not in HEX for char in revision):
|
|
fail("Git returned an invalid source revision")
|
|
return revision
|
|
|
|
|
|
def regular_file(root, relative):
|
|
path = root / relative
|
|
if not path.is_file() or path.is_symlink():
|
|
fail(f"missing regular file: {relative}")
|
|
return path
|
|
|
|
|
|
def read_receipt(path):
|
|
try:
|
|
value = json.loads(regular_file(path.parent, path.name).read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
fail(f"invalid acceptance receipt: {error}")
|
|
if not isinstance(value, dict) or not {"schema_version", "source_revision", "artifacts"} <= set(value) or value["schema_version"] != 1:
|
|
fail("the acceptance receipt has an invalid shape")
|
|
revision = value["source_revision"]
|
|
if not isinstance(revision, str) or len(revision) != 40 or any(char not in HEX for char in revision):
|
|
fail("the acceptance receipt has an invalid source revision")
|
|
artifacts = value["artifacts"]
|
|
if not isinstance(artifacts, list) or len(artifacts) <= len(PAYLOAD):
|
|
fail("the acceptance receipt has an invalid artifact list")
|
|
recorded = {}
|
|
for item in artifacts:
|
|
if not isinstance(item, dict) or not {"path", "sha256"} <= set(item):
|
|
fail("the acceptance receipt has an invalid artifact")
|
|
relative, digest = item["path"], item["sha256"]
|
|
if not isinstance(relative, str) or not isinstance(digest, str) or len(digest) != 64 or any(char not in HEX for char in digest):
|
|
fail("the acceptance receipt has an invalid artifact")
|
|
if relative in recorded:
|
|
fail("the acceptance receipt repeats an artifact")
|
|
recorded[relative] = digest
|
|
base = {relative for relative, _ in PAYLOAD}
|
|
if not base.issubset(recorded) or any(not (name.startswith("bin/") and cohort.module_name(name[4:])) for name in set(recorded) - base):
|
|
fail("the acceptance receipt does not name the full payload")
|
|
return revision, recorded
|
|
|
|
|
|
def read_test_receipt(path):
|
|
try:
|
|
value = json.loads(regular_file(path.parent, path.name).read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
fail(f"invalid test receipt: {error}")
|
|
if not isinstance(value, dict) or not {"schema_version", "source_revision", "tests"} <= set(value) or value["schema_version"] != 1:
|
|
fail("the test receipt has an invalid shape")
|
|
revision, tests = value["source_revision"], value["tests"]
|
|
if not isinstance(revision, str) or len(revision) != 40 or any(char not in HEX for char in revision):
|
|
fail("the test receipt has an invalid source revision")
|
|
if not isinstance(tests, list) or not tests:
|
|
fail("the test receipt has no test evidence")
|
|
for test in tests:
|
|
if not isinstance(test, dict) or not {"command", "result"} <= set(test) or not isinstance(test["command"], str) or not test["command"] or test["result"] != "passed":
|
|
fail("the test receipt has invalid test evidence")
|
|
return revision
|
|
|
|
|
|
def validate_receipt(bundle):
|
|
revision, recorded = read_receipt(bundle / RECEIPT)
|
|
if set(recorded) != {relative for relative, _ in payload(bundle)}:
|
|
fail("the acceptance receipt does not name the full payload")
|
|
for relative, _ in payload(bundle):
|
|
if recorded[relative] != sha256(regular_file(bundle, relative)):
|
|
fail(f"the payload digest does not match the acceptance receipt: {relative}")
|
|
validate_module_archive(bundle, recorded)
|
|
return revision
|
|
|
|
|
|
def command_stage(args):
|
|
root = args.source.resolve()
|
|
revision = clean_revision(root)
|
|
if args.revision and args.revision != revision:
|
|
fail("the requested revision does not match HEAD")
|
|
acceptance = regular_file(args.acceptance_receipt.parent, args.acceptance_receipt.name)
|
|
test_receipt = regular_file(args.test_receipt.parent, args.test_receipt.name)
|
|
receipt_revision, recorded = read_receipt(acceptance)
|
|
if receipt_revision != revision or read_test_receipt(test_receipt) != revision:
|
|
fail("an external receipt does not match the clean source revision")
|
|
if set(recorded) != {relative for relative, _ in payload(root)}:
|
|
fail("the acceptance receipt does not name the full payload")
|
|
for relative, _ in payload(root):
|
|
if recorded[relative] != sha256(regular_file(root, relative)):
|
|
fail(f"the acceptance receipt does not match the source artifact: {relative}")
|
|
stage = args.stage
|
|
if stage.exists() or stage.is_symlink():
|
|
fail(f"the stage already exists: {stage}")
|
|
stage.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = pathlib.Path(tempfile.mkdtemp(prefix=".capy-arty-", dir=stage.parent))
|
|
try:
|
|
for relative, mode in payload(root):
|
|
destination = temporary / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(regular_file(root, relative), destination)
|
|
os.chmod(destination, mode)
|
|
shutil.copyfile(acceptance, temporary / RECEIPT)
|
|
shutil.copyfile(test_receipt, temporary / TEST_RECEIPT)
|
|
os.chmod(temporary / RECEIPT, 0o644)
|
|
os.chmod(temporary / TEST_RECEIPT, 0o644)
|
|
write_module_archive(temporary)
|
|
validate_receipt(temporary)
|
|
os.replace(temporary, stage)
|
|
except BaseException:
|
|
shutil.rmtree(temporary, ignore_errors=True)
|
|
raise
|
|
|
|
|
|
def command_manifest(args):
|
|
revision = validate_receipt(args.stage)
|
|
if read_test_receipt(args.stage / TEST_RECEIPT) != revision:
|
|
fail("the staged test receipt does not match the acceptance receipt")
|
|
if clean_revision(args.source.resolve()) != revision:
|
|
fail("the stage acceptance receipt does not match the clean source revision")
|
|
try:
|
|
subprocess.run(
|
|
[sys.executable, str(args.release_helper), "manifest", "deploy/arty.json", "--stage", str(args.stage), "--output", str(args.output)],
|
|
cwd=args.source, check=True, timeout=args.timeout,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
fail(f"the shared release helper failed: {error}")
|
|
if clean_revision(args.source.resolve()) != revision:
|
|
args.output.unlink(missing_ok=True)
|
|
fail("the source revision changed while the manifest was created")
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
subcommands = parser.add_subparsers(required=True)
|
|
stage = subcommands.add_parser("stage")
|
|
stage.add_argument("--stage", type=pathlib.Path, required=True)
|
|
stage.add_argument("--acceptance-receipt", type=pathlib.Path, required=True)
|
|
stage.add_argument("--test-receipt", type=pathlib.Path, required=True)
|
|
stage.add_argument("--source", type=pathlib.Path, default=ROOT)
|
|
stage.add_argument("--revision")
|
|
stage.set_defaults(command=command_stage)
|
|
manifest = subcommands.add_parser("manifest")
|
|
manifest.add_argument("--stage", type=pathlib.Path, required=True)
|
|
manifest.add_argument("--output", type=pathlib.Path, required=True)
|
|
manifest.add_argument("--source", type=pathlib.Path, default=ROOT)
|
|
manifest.add_argument("--release-helper", type=pathlib.Path, default=SHARED_HELPER)
|
|
manifest.add_argument("--timeout", type=int, default=60)
|
|
manifest.set_defaults(command=command_manifest)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
try:
|
|
if hasattr(args, "timeout") and not 1 <= args.timeout <= 7200:
|
|
fail("timeout must be between 1 and 7200 seconds")
|
|
args.command(args)
|
|
except (OSError, subprocess.SubprocessError, ValueError, tarfile.TarError) as error:
|
|
print(f"arty-release-pilot: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|