sandcastle-ng

Scripts for the deployment of Sandcastle (GNU Taler)
Log | Files | Refs | README

commit b216cf3edee8baa7d584c8bfd65f2a81d01c96c4
parent 26b92a100087858ac5a971dfaa155cbe6813765c
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 15:04:33 +0200

sandcastle: build component packages incrementally

We now use a similar approach as taler-pkg from packaging/ng.

Diffstat:
A.containerignore | 7+++++++
M.gitignore | 3+++
MDockerfile | 97+++++--------------------------------------------------------------------------
MREADME.md | 10++++++++++
Mbuildscripts/sandcastle-build-generic | 65+++++++++++++++++++++++++++++++++++++++++------------------------
Abuildscripts/sandcastle_build_packages.py | 387+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msandcastle-build | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Atests/test_sandcastle_build_packages.py | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 803 insertions(+), 127 deletions(-)

diff --git a/.containerignore b/.containerignore @@ -0,0 +1,7 @@ +.git/ +cache/ +credentials/ +exported/ +packages/.state/ +packages/.staging/ +**/__pycache__/ diff --git a/.gitignore b/.gitignore @@ -1,2 +1,5 @@ /credentials/* /exported/* +/cache/ +/packages/ +__pycache__/ diff --git a/Dockerfile b/Dockerfile @@ -72,82 +72,9 @@ RUN DEBIAN_FRONTEND=noninteractive \ equivs \ ; -# FIXME: Try to use debian packages where possible +# FIXME: Try to use Debian packages where possible. RUN npm install -g pnpm@10 -COPY buildscripts/sandcastle-build-generic /bin/ - -# GNUnet -FROM base-system AS gnunet -COPY buildconfig/gnunet.* /buildconfig/ -RUN sandcastle-build-generic gnunet - -# Directory -FROM base-system AS taler-directory -COPY buildconfig/taler-directory.* /buildconfig/ -RUN sandcastle-build-generic taler-directory - -# Mailbox -FROM base-system AS taler-mailbox -COPY buildconfig/taler-mailbox.* /buildconfig/ -RUN sandcastle-build-generic taler-mailbox - -# Exchange -FROM gnunet as taler-exchange -COPY buildconfig/taler-exchange.* /buildconfig/ -RUN sandcastle-build-generic taler-exchange - -# Auditor Webui -FROM base-system as taler-auditor-webui -COPY buildconfig/taler-auditor-webui.* /buildconfig/ -RUN sandcastle-build-generic taler-auditor-webui - -# Donau -FROM taler-exchange as donau -COPY buildconfig/donau.* /buildconfig/ -RUN sandcastle-build-generic donau - -# Merchant -FROM donau as taler-merchant -COPY buildconfig/taler-merchant.* /buildconfig/ -RUN sandcastle-build-generic taler-merchant - -# Paivana (needs libtalermerchant from the merchant) -FROM taler-merchant as paivana-httpd -COPY buildconfig/paivana-httpd.* /buildconfig/ -RUN sandcastle-build-generic paivana-httpd - -# Challenger -FROM base-system as challenger-webui -COPY buildconfig/challenger-webui.* /buildconfig/ -RUN sandcastle-build-generic challenger-webui - -FROM taler-exchange as challenger -COPY buildconfig/challenger.* /buildconfig/ -RUN sandcastle-build-generic challenger - -# Libeufin -FROM base-system as libeufin -COPY buildconfig/libeufin.* /buildconfig/ -RUN sandcastle-build-generic libeufin - -# Merchant demos -FROM base-system as taler-merchant-demos -COPY buildconfig/taler-merchant-demos.* /buildconfig/ -RUN sandcastle-build-generic taler-merchant-demos - -FROM base-system as taler-wallet-cli -COPY buildconfig/taler-wallet-cli.* /buildconfig/ -RUN sandcastle-build-generic taler-wallet-cli - -FROM base-system as taler-harness -COPY buildconfig/taler-harness.* /buildconfig/ -RUN sandcastle-build-generic taler-harness - -FROM base-system as taler-merchant-webui -COPY buildconfig/taler-merchant-webui.* /buildconfig/ -RUN sandcastle-build-generic taler-merchant-webui - FROM base-system as turnstile COPY buildconfig/turnstile.* /buildconfig/ RUN TAG=$(cat /buildconfig/turnstile.tag) && \ @@ -184,25 +111,13 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get -y upgrade && apt-g php-curl \ ; -RUN mkdir -p /packages -COPY --from=gnunet /packages/gnunet/* /packages/ -COPY --from=taler-directory /packages/taler-directory/* /packages/ -COPY --from=taler-mailbox /packages/taler-mailbox/* /packages/ -COPY --from=taler-exchange /packages/taler-exchange/* /packages/ -COPY --from=taler-merchant /packages/taler-merchant/* /packages/ -COPY --from=taler-wallet-cli /packages/taler-wallet-cli/* /packages/ -COPY --from=taler-harness /packages/taler-harness/* /packages/ -COPY --from=taler-merchant-webui /packages/taler-merchant-webui/* /packages/ -COPY --from=taler-auditor-webui /packages/taler-auditor-webui/* /packages/ -COPY --from=libeufin /packages/libeufin/* /packages/ -COPY --from=taler-merchant-demos /packages/taler-merchant-demos/* /packages/ -COPY --from=challenger-webui /packages/challenger-webui/* /packages/ -COPY --from=challenger /packages/challenger/* /packages/ -COPY --from=donau /packages/donau/* /packages/ -COPY --from=paivana-httpd /packages/paivana-httpd/* /packages/ +COPY packages/debian-trixie/ /packages/ COPY --from=turnstile /opt/turnstile /opt/turnstile RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get -y upgrade && \ - apt-get install --no-install-recommends -y /packages/*.deb + package_dir="/packages/$(dpkg --print-architecture)" && \ + test -d "$package_dir" && \ + find "$package_dir" -type f -name '*.deb' -print0 | \ + xargs -0 apt-get install --no-install-recommends -y COPY systemd/setup-sandcastle.service /etc/systemd/system/ RUN systemctl enable setup-sandcastle.service # Disable potentially problem-causing services diff --git a/README.md b/README.md @@ -59,6 +59,16 @@ use `./print-latest-versions`. 2. Run `./sandcastle-build` to build the Taler container. The resulting container will be tagged as `taler-base-all`. + Package builds are incremental. Built Debian packages and their build keys + are kept below `packages/`, while downloaded build data is kept below + `cache/`. A component is rebuilt when its build configuration, package + builder, generic build logic, or one of its package dependencies changes. + Unrelated packages remain untouched. Failed builds are not recorded as + current, so they are retried on the next invocation. + + Use `./sandcastle-build --no-cache` to rebuild the package builder, every + component package, and the final image without cached image layers. + By default `sandcastle-build` builds from the `Dockerfile` in the same directory as the script. Anyhow you can pass a different container file like this: diff --git a/buildscripts/sandcastle-build-generic b/buildscripts/sandcastle-build-generic @@ -10,12 +10,11 @@ import glob import os -import shlex import shutil import subprocess import sys from email.utils import formatdate -from logging import log, warning +from logging import warning def run_cmd(cmd, shell=False, cwd=None, env=None): @@ -39,27 +38,39 @@ def get_tag_debver(tag): if d < 0: return tag[1:] else: - return tag[1:d] + "~dev" + tag[d + len(devsuff)] + return tag[1:d] + "~dev" + tag[d + len(devsuff) :] if tag.startswith("deb-v"): tag = tag[5:] if "-" in tag: - a, b = tag.split("-") - return a + ":" + b + upstream, revision = tag.split("-", 1) + return upstream + "-" + revision return tag warning(f"unexpected tag format: {tag}") return "100.0.0" # Version big enough to work every time +def make_codename_version(deb_version, codename): + """Make locally-built packages distinct from other distro builds.""" + if "-" in deb_version: + return f"{deb_version}+{codename}" + return f"{deb_version}-0+{codename}" + + def main(): if "LD_LIBRARY_PATH" in os.environ: del os.environ["LD_LIBRARY_PATH"] # Arguments - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} <PACKAGE>", file=sys.stderr) + if len(sys.argv) != 4: + print( + f"Usage: {sys.argv[0]} <PACKAGE> <CODENAME> <ARCH>", + file=sys.stderr, + ) sys.exit(1) package = sys.argv[1] + codename = sys.argv[2] + architecture = sys.argv[3] # Path of the debian/ folder in the repository DEBIANPATH = "" @@ -68,22 +79,31 @@ def main(): with open(debpath_file, "r") as f: DEBIANPATH = f.read().strip() - print(f"Building {package} with generic build logic", file=sys.stderr) + print( + f"Building {package} for {codename}/{architecture} with generic build logic", + file=sys.stderr, + ) with open(f"/buildconfig/{package}.tag", "r") as f: tag = f.read().strip() - outdir = f"/packages/{package}/" - + outdir = "/out" os.makedirs(outdir, exist_ok=True) - os.chdir("/packages/") + os.chdir("/pkgdir") - # Using shell=True here to easily handle the pipe logic - run_cmd("dpkg-scanpackages . | xz - > /packages/Packages.xz", shell=True) + run_cmd( + [ + "bash", + "-o", + "pipefail", + "-c", + "dpkg-scanpackages . /dev/null | xz - > Packages.xz", + ] + ) with open("/etc/apt/sources.list.d/taler-packaging-local.list", "w") as f: - f.write("deb [trusted=yes] file:/packages ./\n") + f.write("deb [trusted=yes] file:/pkgdir ./\n") run_cmd(["apt-get", "update"]) @@ -101,7 +121,7 @@ def main(): build_pkg_path = os.path.join("/build", package, DEBIANPATH) os.chdir(build_pkg_path) - deb_version = get_tag_debver(tag) + deb_version = make_codename_version(get_tag_debver(tag), codename) # Bootstrap and Install Deps os.chdir(os.path.join("/build", package)) @@ -142,16 +162,13 @@ def main(): # Build Package run_cmd(["dpkg-buildpackage", "-rfakeroot", "-b", "-uc", "-us"]) - # Copy artifacts - # Globs for ../*.deb and ../*.ddeb relative to current dir + # Copy binary packages into the host-side staging directory. Debug packages + # are intentionally omitted from the final sandcastle image. deb_files = glob.glob("../*.deb") - ddeb_files = glob.glob("../*.ddeb") - - for f in deb_files: - shutil.copy(f, outdir) - - for f in ddeb_files: - shutil.copy(f, outdir) + if not deb_files: + raise RuntimeError(f"build of {package} produced no .deb packages") + for deb_file in deb_files: + shutil.copy(deb_file, outdir) if __name__ == "__main__": diff --git a/buildscripts/sandcastle_build_packages.py b/buildscripts/sandcastle_build_packages.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# This file is in the public domain. +"""Build sandcastle Debian packages incrementally in fresh containers.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import uuid +from collections.abc import Callable, Iterable, Mapping, Sequence +from pathlib import Path + + +DISTRO = "debian-trixie" +BUILDER_IMAGE = "localhost/taler-sandcastle-package-builder:latest" + +# Keep this list in sync with the packages installed by the final image. +COMPONENTS = ( + "gnunet", + "taler-directory", + "taler-mailbox", + "taler-exchange", + "taler-auditor-webui", + "donau", + "taler-merchant", + "paivana-httpd", + "challenger-webui", + "challenger", + "libeufin", + "taler-merchant-demos", + "taler-wallet-cli", + "taler-harness", + "taler-merchant-webui", +) + +DEPENDENCIES = { + "taler-exchange": ("gnunet",), + "donau": ("gnunet", "taler-exchange"), + "taler-merchant": ("gnunet", "taler-exchange", "donau"), + "challenger": ("taler-exchange",), + "paivana-httpd": ("gnunet", "taler-exchange", "taler-merchant"), +} + +BuildRunner = Callable[[str, Path], None] + + +def topological_order( + components: Sequence[str], dependencies: Mapping[str, Sequence[str]] +) -> list[str]: + """Return components with every dependency before its consumers.""" + component_set = set(components) + if len(component_set) != len(components): + raise ValueError("component list contains duplicates") + + order: list[str] = [] + permanent: set[str] = set() + temporary: set[str] = set() + + def visit(component: str) -> None: + if component in permanent: + return + if component in temporary: + raise ValueError(f"dependency cycle involving {component}") + if component not in component_set: + raise ValueError(f"unknown component in dependency graph: {component}") + + temporary.add(component) + for dependency in dependencies.get(component, ()): + visit(dependency) + temporary.remove(component) + permanent.add(component) + order.append(component) + + for component in components: + visit(component) + return order + + +def file_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def component_input_digest(config_dir: Path, component: str) -> str: + """Hash all build configuration that belongs to one component.""" + inputs: dict[str, str | None] = {} + required_suffixes = ("tag", "giturl") + optional_suffixes = ("debpath", "checkout") + for suffix in required_suffixes + optional_suffixes: + path = config_dir / f"{component}.{suffix}" + if suffix in required_suffixes and not path.is_file(): + raise FileNotFoundError(f"missing build configuration: {path}") + inputs[suffix] = file_digest(path) if path.is_file() else None + return hashlib.sha256(canonical_json(inputs)).hexdigest() + + +def canonical_json(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def desired_build_keys( + order: Sequence[str], + dependencies: Mapping[str, Sequence[str]], + component_inputs: Mapping[str, str], + *, + distro: str, + architecture: str, + builder_image_id: str, + build_script_digest: str, +) -> dict[str, str]: + """Compute recursive keys so dependency changes invalidate consumers.""" + keys: dict[str, str] = {} + for component in order: + payload = { + "schema": 1, + "component": component, + "component_input": component_inputs[component], + "distro": distro, + "architecture": architecture, + "builder_image_id": builder_image_id, + "build_script": build_script_digest, + "dependencies": { + dependency: keys[dependency] + for dependency in sorted(dependencies.get(component, ())) + }, + } + keys[component] = hashlib.sha256(canonical_json(payload)).hexdigest() + return keys + + +def state_path(state_dir: Path, component: str) -> Path: + return state_dir / f"{component}.key" + + +def package_files(artifact_dir: Path, component: str) -> list[Path]: + return sorted((artifact_dir / component).glob("*.deb")) + + +def component_is_current( + artifact_dir: Path, state_dir: Path, component: str, desired_key: str +) -> bool: + marker = state_path(state_dir, component) + try: + recorded_key = marker.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return False + return recorded_key == desired_key and bool(package_files(artifact_dir, component)) + + +def write_state_atomically(path: Path, desired_key: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + temporary.write_text(f"{desired_key}\n", encoding="utf-8") + os.replace(temporary, path) + + +def promote_artifacts(staging_dir: Path, artifact_dir: Path, component: str) -> None: + """Replace a component's artifacts while retaining the old set on failure.""" + destination = artifact_dir / component + artifact_dir.mkdir(parents=True, exist_ok=True) + backup = staging_dir.with_name(f".{component}.{uuid.uuid4().hex}.old") + + if destination.exists(): + os.replace(destination, backup) + try: + os.replace(staging_dir, destination) + except BaseException: + if backup.exists(): + os.replace(backup, destination) + raise + if backup.exists(): + shutil.rmtree(backup) + + +def rebuild_outdated( + order: Sequence[str], + desired_keys: Mapping[str, str], + artifact_dir: Path, + state_dir: Path, + staging_root: Path, + run_build: BuildRunner, + *, + force: bool = False, +) -> list[str]: + """Build outdated components in order and promote only successful outputs.""" + rebuilt: list[str] = [] + for component in order: + desired_key = desired_keys[component] + if not force and component_is_current( + artifact_dir, state_dir, component, desired_key + ): + print(f"Package {component}: up to date") + continue + + print(f"Package {component}: rebuilding") + staging_dir = staging_root / f"{component}.{uuid.uuid4().hex}" + staging_dir.mkdir(parents=True, exist_ok=False) + try: + run_build(component, staging_dir) + outputs = sorted(staging_dir.glob("*.deb")) + if not outputs: + raise RuntimeError(f"build of {component} produced no .deb packages") + promote_artifacts(staging_dir, artifact_dir, component) + write_state_atomically(state_path(state_dir, component), desired_key) + rebuilt.append(component) + finally: + if staging_dir.exists(): + shutil.rmtree(staging_dir) + return rebuilt + + +def inspect_builder(image: str) -> tuple[str, str]: + result = subprocess.run( + ["podman", "image", "inspect", "--format", "{{.Id}} {{.Architecture}}", image], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + fields = result.stdout.strip().split() + if len(fields) != 2: + raise RuntimeError(f"could not determine identity and architecture of {image}") + return fields[0], fields[1] + + +def ensure_directories(paths: Iterable[Path]) -> None: + for path in paths: + path.mkdir(parents=True, exist_ok=True) + + +def podman_build_runner( + *, + root: Path, + image: str, + architecture: str, + distro: str, + codename: str, + artifact_dir: Path, + cache_dir: Path, +) -> BuildRunner: + buildscripts_dir = root / "buildscripts" + buildconfig_dir = root / "buildconfig" + mounts = ( + (cache_dir / "gradle", "/root/.gradle/caches"), + (cache_dir / "pnpm", "/root/.local/share/pnpm/store"), + (cache_dir / "cargo-registry", "/root/.cargo/registry"), + (cache_dir / "cargo-git", "/root/.cargo/git"), + (cache_dir / "cargo-build", "/root/.cargo-build"), + (cache_dir / distro / "apt-archives", "/var/cache/apt/archives"), + (cache_dir / distro / "apt-lists", "/var/lib/apt/lists"), + ) + + def run_build(component: str, staging_dir: Path) -> None: + command = [ + "podman", + "run", + "--rm", + "--arch", + architecture, + "--ulimit=nofile=2048:2048", + "--entrypoint=/bin/python3", + "--security-opt", + "label=disable", + ] + for source, target in mounts: + command.extend(["--mount", f"type=bind,source={source},target={target}"]) + command.extend( + [ + "--env", + "CARGO_BUILD_BUILD_DIR=/root/.cargo-build", + "--mount", + f"type=bind,source={buildscripts_dir},target=/buildscripts,readonly", + "--mount", + f"type=bind,source={buildconfig_dir},target=/buildconfig,readonly", + "--mount", + f"type=bind,source={artifact_dir},target=/pkgdir", + "--mount", + f"type=bind,source={staging_dir},target=/out", + image, + "/buildscripts/sandcastle-build-generic", + component, + codename, + architecture, + ] + ) + subprocess.run(command, check=True) + + return run_build + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--builder-image", default=BUILDER_IMAGE) + parser.add_argument("--distro", default=DISTRO) + parser.add_argument("--force", action="store_true") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + root = Path(__file__).resolve().parents[1] + config_dir = root / "buildconfig" + generic_script = root / "buildscripts" / "sandcastle-build-generic" + artifact_dir = root / "packages" / args.distro + state_dir = root / "packages" / ".state" / args.distro + staging_root = root / "packages" / ".staging" / args.distro + cache_dir = root / "cache" + + vendor, separator, codename = args.distro.partition("-") + if not separator or vendor != "debian" or not codename: + raise ValueError(f"unsupported distribution: {args.distro}") + + builder_image_id, architecture = inspect_builder(args.builder_image) + artifact_dir /= architecture + state_dir /= architecture + staging_root /= architecture + + cache_paths = ( + cache_dir / "gradle", + cache_dir / "pnpm", + cache_dir / "cargo-registry", + cache_dir / "cargo-git", + cache_dir / "cargo-build", + cache_dir / args.distro / "apt-archives" / "partial", + cache_dir / args.distro / "apt-lists" / "partial", + artifact_dir, + state_dir, + staging_root, + ) + ensure_directories(cache_paths) + + order = topological_order(COMPONENTS, DEPENDENCIES) + component_inputs = { + component: component_input_digest(config_dir, component) for component in order + } + keys = desired_build_keys( + order, + DEPENDENCIES, + component_inputs, + distro=args.distro, + architecture=architecture, + builder_image_id=builder_image_id, + build_script_digest=file_digest(generic_script), + ) + runner = podman_build_runner( + root=root, + image=args.builder_image, + architecture=architecture, + distro=args.distro, + codename=codename, + artifact_dir=artifact_dir, + cache_dir=cache_dir, + ) + rebuilt = rebuild_outdated( + order, + keys, + artifact_dir, + state_dir, + staging_root, + runner, + force=args.force, + ) + + missing = [ + component + for component in order + if not component_is_current(artifact_dir, state_dir, component, keys[component]) + ] + if missing: + raise RuntimeError("packages are not current: " + ", ".join(missing)) + + if rebuilt: + print("Rebuilt packages: " + ", ".join(rebuilt)) + else: + print("All packages are up to date") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sandcastle-build b/sandcastle-build @@ -2,30 +2,71 @@ set -eu -no_cache= +no_cache_args=() +package_args=() +containerfile=Dockerfile +containerfile_set= + while [[ $# -gt 0 ]]; do case $1 in - --no-cache) no_cache=--no-cache ;; - *) - echo "Unknown option '$1'" + --no-cache) + no_cache_args=(--no-cache) + package_args=(--force) + ;; + -*) + echo >&2 "Unknown option '$1'" exit 1 ;; + *) + if [[ -n $containerfile_set ]]; then + echo >&2 "Unexpected argument '$1'" + exit 1 + fi + containerfile=$1 + containerfile_set=yes + ;; esac shift done -CONTAINERFILE="${1:-Dockerfile}" -[ $# -gt 0 ] && shift - SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) cd "$SCRIPT_DIR" -if [ ! -r "${CONTAINERFILE}" ]; then - echo >&2 "Containerfile '${CONTAINERFILE}' not found or not readable." +if [[ ! -r $containerfile ]]; then + echo >&2 "Containerfile '${containerfile}' not found or not readable." exit 1 fi -echo "Building with containerfile: ${CONTAINERFILE}" + +builder_image=localhost/taler-sandcastle-package-builder:latest +cache_dir=$SCRIPT_DIR/cache +mkdir -p \ + "$cache_dir/debian-trixie/apt-archives/partial" \ + "$cache_dir/debian-trixie/apt-lists/partial" + # The nofile ulimit is required to prevent fakeroot from becoming sluggish. -exec podman build --log-level=debug $no_cache --ulimit=nofile=2048:2048 -f "${CONTAINERFILE}" \ - --target taler-final -t taler-base-all "$@" . +build_args=( + podman build + --log-level=debug + --ulimit=nofile=2048:2048 + -f "$containerfile" +) + +echo "Building package-builder image with containerfile: $containerfile" +"${build_args[@]}" "${no_cache_args[@]}" \ + --volume "$cache_dir/debian-trixie/apt-archives:/var/cache/apt/archives:z" \ + --volume "$cache_dir/debian-trixie/apt-lists:/var/lib/apt/lists:z" \ + --target base-system \ + --tag "$builder_image" \ + . + +echo "Building outdated Debian packages" +python3 buildscripts/sandcastle_build_packages.py \ + --builder-image "$builder_image" \ + "${package_args[@]}" + +echo "Building final sandcastle image" +exec "${build_args[@]}" "${no_cache_args[@]}" \ + --target taler-final \ + --tag taler-base-all \ + . diff --git a/tests/test_sandcastle_build_packages.py b/tests/test_sandcastle_build_packages.py @@ -0,0 +1,296 @@ +# This file is in the public domain. + +import os +import runpy +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from buildscripts.sandcastle_build_packages import ( + COMPONENTS, + DEPENDENCIES, + component_input_digest, + component_is_current, + desired_build_keys, + podman_build_runner, + rebuild_outdated, + topological_order, +) + + +class DebianVersionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + script = ( + Path(__file__).resolve().parents[1] + / "buildscripts" + / "sandcastle-build-generic" + ) + cls.generic = runpy.run_path(str(script)) + + def test_development_tag_keeps_the_complete_sequence_number(self): + self.assertEqual("1.2.3~dev17", self.generic["get_tag_debver"]("v1.2.3-dev.17")) + + def test_debian_revision_and_codename_are_preserved(self): + version = self.generic["get_tag_debver"]("deb-v1.2.3-4") + with_codename = self.generic["make_codename_version"](version, "trixie") + self.assertEqual("1.2.3-4+trixie", with_codename) + + +class DependencyKeyTests(unittest.TestCase): + dependencies = {"b": ("a",), "c": ("b",)} + components = ("c", "d", "b", "a") + + def keys(self, inputs): + order = topological_order(self.components, self.dependencies) + return desired_build_keys( + order, + self.dependencies, + inputs, + distro="debian-trixie", + architecture="amd64", + builder_image_id="builder-1", + build_script_digest="script-1", + ) + + def test_topological_order_puts_dependencies_first(self): + order = topological_order(self.components, self.dependencies) + self.assertLess(order.index("a"), order.index("b")) + self.assertLess(order.index("b"), order.index("c")) + self.assertEqual(set(self.components), set(order)) + + def test_changed_package_invalidates_only_it_and_reverse_dependencies(self): + before = self.keys({"a": "a1", "b": "b1", "c": "c1", "d": "d1"}) + after = self.keys({"a": "a2", "b": "b1", "c": "c1", "d": "d1"}) + changed = { + component for component in before if before[component] != after[component] + } + self.assertEqual({"a", "b", "c"}, changed) + + def test_changed_consumer_does_not_invalidate_its_dependency(self): + before = self.keys({"a": "a1", "b": "b1", "c": "c1", "d": "d1"}) + after = self.keys({"a": "a1", "b": "b2", "c": "c1", "d": "d1"}) + changed = { + component for component in before if before[component] != after[component] + } + self.assertEqual({"b", "c"}, changed) + + def test_cycles_are_rejected(self): + with self.assertRaisesRegex(ValueError, "cycle"): + topological_order(("a", "b"), {"a": ("b",), "b": ("a",)}) + + def test_exchange_change_rebuilds_its_real_reverse_dependencies(self): + order = topological_order(COMPONENTS, DEPENDENCIES) + inputs = {component: "unchanged" for component in COMPONENTS} + before = desired_build_keys( + order, + DEPENDENCIES, + inputs, + distro="debian-trixie", + architecture="amd64", + builder_image_id="builder-1", + build_script_digest="script-1", + ) + inputs["taler-exchange"] = "changed" + after = desired_build_keys( + order, + DEPENDENCIES, + inputs, + distro="debian-trixie", + architecture="amd64", + builder_image_id="builder-1", + build_script_digest="script-1", + ) + changed = { + component + for component in COMPONENTS + if before[component] != after[component] + } + self.assertEqual( + { + "taler-exchange", + "donau", + "taler-merchant", + "paivana-httpd", + "challenger", + }, + changed, + ) + + +class ComponentInputDigestTests(unittest.TestCase): + def test_checkout_pin_changes_component_digest(self): + with tempfile.TemporaryDirectory() as temporary: + config_dir = Path(temporary) + (config_dir / "component.tag").write_text("master\n", encoding="utf-8") + (config_dir / "component.giturl").write_text( + "https://example.com/component.git\n", encoding="utf-8" + ) + + without_checkout = component_input_digest(config_dir, "component") + (config_dir / "component.checkout").write_text( + "first-commit\n", encoding="utf-8" + ) + first_checkout = component_input_digest(config_dir, "component") + (config_dir / "component.checkout").write_text( + "second-commit\n", encoding="utf-8" + ) + second_checkout = component_input_digest(config_dir, "component") + + self.assertNotEqual(without_checkout, first_checkout) + self.assertNotEqual(first_checkout, second_checkout) + + +class ArtifactStateTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + root = Path(self.temporary.name) + self.artifacts = root / "artifacts" + self.state = root / "state" + self.staging = root / "staging" + self.artifacts.mkdir() + self.state.mkdir() + self.staging.mkdir() + + def tearDown(self): + self.temporary.cleanup() + + def record_old_build(self): + component_dir = self.artifacts / "a" + component_dir.mkdir() + (component_dir / "old.deb").write_bytes(b"old") + (self.state / "a.key").write_text("old-key\n", encoding="utf-8") + + def test_failed_build_keeps_previous_artifacts_and_key(self): + self.record_old_build() + + def fail_build(_component, staging_dir): + (staging_dir / "new.deb").write_bytes(b"new") + raise RuntimeError("build failed") + + with self.assertRaisesRegex(RuntimeError, "build failed"): + rebuild_outdated( + ["a"], + {"a": "new-key"}, + self.artifacts, + self.state, + self.staging, + fail_build, + ) + + self.assertEqual(b"old", (self.artifacts / "a" / "old.deb").read_bytes()) + self.assertEqual("old-key", (self.state / "a.key").read_text().strip()) + self.assertFalse( + component_is_current(self.artifacts, self.state, "a", "new-key") + ) + self.assertEqual([], list(self.staging.iterdir())) + + def test_successful_build_promotes_artifacts_and_key(self): + self.record_old_build() + + def succeed_build(_component, staging_dir): + (staging_dir / "new.deb").write_bytes(b"new") + + rebuilt = rebuild_outdated( + ["a"], + {"a": "new-key"}, + self.artifacts, + self.state, + self.staging, + succeed_build, + ) + + self.assertEqual(["a"], rebuilt) + self.assertFalse((self.artifacts / "a" / "old.deb").exists()) + self.assertEqual(b"new", (self.artifacts / "a" / "new.deb").read_bytes()) + self.assertTrue( + component_is_current(self.artifacts, self.state, "a", "new-key") + ) + + def test_matching_key_without_an_artifact_is_not_current(self): + (self.state / "a.key").write_text("key\n", encoding="utf-8") + self.assertFalse(component_is_current(self.artifacts, self.state, "a", "key")) + + +class PackageRunnerTests(unittest.TestCase): + @patch("buildscripts.sandcastle_build_packages.subprocess.run") + def test_package_container_has_bounded_nofile_limit(self, run): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runner = podman_build_runner( + root=root, + image="builder-image", + architecture="amd64", + distro="debian-trixie", + codename="trixie", + artifact_dir=root / "artifacts", + cache_dir=root / "cache", + ) + + runner("component", root / "staging") + + command = run.call_args.args[0] + self.assertIn("--ulimit=nofile=2048:2048", command) + run.assert_called_once_with(command, check=True) + + +class BuildOrchestrationTests(unittest.TestCase): + def test_build_phases_and_no_cache_propagation(self): + repository = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "bin").mkdir() + (root / "buildscripts").mkdir() + shutil.copy(repository / "sandcastle-build", root / "sandcastle-build") + (root / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + (root / "buildscripts" / "sandcastle_build_packages.py").touch() + log = root / "calls" + + fake_command = '#!/bin/sh\nprintf \'%s\' "$0" >> "$CALL_LOG"\nprintf \' <%s>\' "$@" >> "$CALL_LOG"\nprintf \'\\n\' >> "$CALL_LOG"\n' + for command in ("podman", "python3"): + executable = root / "bin" / command + executable.write_text(fake_command, encoding="utf-8") + executable.chmod(0o755) + + environment = os.environ.copy() + environment["PATH"] = f"{root / 'bin'}:{environment['PATH']}" + environment["CALL_LOG"] = str(log) + subprocess.run( + [str(root / "sandcastle-build"), "--no-cache"], + check=True, + cwd=root, + env=environment, + stdout=subprocess.PIPE, + text=True, + ) + + calls = log.read_text(encoding="utf-8").splitlines() + self.assertEqual(3, len(calls)) + self.assertIn("<--target> <base-system>", calls[0]) + self.assertIn("<--no-cache>", calls[0]) + self.assertIn("<buildscripts/sandcastle_build_packages.py>", calls[1]) + self.assertIn("<--force>", calls[1]) + self.assertIn("<--target> <taler-final>", calls[2]) + self.assertIn("<--no-cache>", calls[2]) + + log.write_text("", encoding="utf-8") + subprocess.run( + [str(root / "sandcastle-build")], + check=True, + cwd=root, + env=environment, + stdout=subprocess.PIPE, + text=True, + ) + calls = log.read_text(encoding="utf-8").splitlines() + self.assertEqual(3, len(calls)) + self.assertNotIn("<--no-cache>", calls[0]) + self.assertNotIn("<--force>", calls[1]) + self.assertNotIn("<--no-cache>", calls[2]) + + +if __name__ == "__main__": + unittest.main()