misc.python.materialize.mzbuild
The implementation of the mzbuild system for Docker images.
For an overview of what mzbuild is and why it exists, see the user-facing documentation.
1# Copyright Materialize, Inc. and contributors. All rights reserved. 2# 3# Use of this software is governed by the Business Source License 4# included in the LICENSE file at the root of this repository. 5# 6# As of the Change Date specified in that file, in accordance with 7# the Business Source License, use of this software will be governed 8# by the Apache License, Version 2.0. 9 10"""The implementation of the mzbuild system for Docker images. 11 12For an overview of what mzbuild is and why it exists, see the [user-facing 13documentation][user-docs]. 14 15[user-docs]: https://github.com/MaterializeInc/materialize/blob/main/doc/developer/mzbuild.md 16""" 17 18import argparse 19import base64 20import collections 21import hashlib 22import json 23import multiprocessing 24import os 25import re 26import shlex 27import shutil 28import stat 29import subprocess 30import sys 31import tarfile 32import time 33from collections import OrderedDict 34from collections.abc import Callable, Iterable, Iterator, Sequence 35from concurrent.futures import ThreadPoolExecutor 36from enum import Enum, auto 37from functools import cache 38from pathlib import Path 39from tempfile import TemporaryFile 40from typing import IO, Any, cast 41 42import yaml 43 44from materialize import bazel as bazel_utils 45from materialize import cargo, git, rustc_flags, spawn, ui, xcompile 46from materialize.rustc_flags import Sanitizer 47from materialize.xcompile import Arch, target 48 49 50class Fingerprint(bytes): 51 """A SHA-1 hash of the inputs to an `Image`. 52 53 The string representation uses base32 encoding to distinguish mzbuild 54 fingerprints from Git's hex encoded SHA-1 hashes while still being 55 URL safe. 56 """ 57 58 def __str__(self) -> str: 59 return base64.b32encode(self).decode() 60 61 62class Profile(Enum): 63 RELEASE = auto() 64 OPTIMIZED = auto() 65 DEV = auto() 66 67 68class RepositoryDetails: 69 """Immutable details about a `Repository`. 70 71 Used internally by mzbuild. 72 73 Attributes: 74 root: The path to the root of the repository. 75 arch: The CPU architecture to build for. 76 profile: What profile the repository is being built with. 77 coverage: Whether the repository has code coverage instrumentation 78 enabled. 79 sanitizer: Whether to use a sanitizer (address, hwaddress, cfi, thread, leak, memory, none) 80 cargo_workspace: The `cargo.Workspace` associated with the repository. 81 image_registry: The Docker image registry to pull images from and push 82 images to. 83 image_prefix: A prefix to apply to all Docker image names. 84 bazel: Whether or not to use Bazel as the build system instead of Cargo. 85 bazel_remote_cache: URL of a Bazel Remote Cache that we can build with. 86 """ 87 88 def __init__( 89 self, 90 root: Path, 91 arch: Arch, 92 profile: Profile, 93 coverage: bool, 94 sanitizer: Sanitizer, 95 image_registry: str, 96 image_prefix: str, 97 bazel: bool, 98 bazel_remote_cache: str | None, 99 ): 100 self.root = root 101 self.arch = arch 102 self.profile = profile 103 self.coverage = coverage 104 self.sanitizer = sanitizer 105 self.cargo_workspace = cargo.Workspace(root) 106 self.image_registry = image_registry 107 self.image_prefix = image_prefix 108 self.bazel = bazel 109 self.bazel_remote_cache = bazel_remote_cache 110 111 def build( 112 self, 113 subcommand: str, 114 rustflags: list[str], 115 channel: str | None = None, 116 extra_env: dict[str, str] = {}, 117 ) -> list[str]: 118 """Start a build invocation for the configured architecture.""" 119 if self.bazel: 120 assert not channel, "Bazel doesn't support building for multiple channels." 121 return xcompile.bazel( 122 arch=self.arch, 123 subcommand=subcommand, 124 rustflags=rustflags, 125 extra_env=extra_env, 126 ) 127 else: 128 return xcompile.cargo( 129 arch=self.arch, 130 channel=channel, 131 subcommand=subcommand, 132 rustflags=rustflags, 133 extra_env=extra_env, 134 ) 135 136 def tool(self, name: str) -> list[str]: 137 """Start a binutils tool invocation for the configured architecture.""" 138 if self.bazel: 139 return ["bazel", "run", f"@//misc/bazel/tools:{name}", "--"] 140 else: 141 return xcompile.tool(self.arch, name) 142 143 def cargo_target_dir(self) -> Path: 144 """Determine the path to the target directory for Cargo.""" 145 return self.root / "target-xcompile" / xcompile.target(self.arch) 146 147 def bazel_workspace_dir(self) -> Path: 148 """Determine the path to the root of the Bazel workspace.""" 149 return self.root 150 151 def bazel_config(self) -> list[str]: 152 """Returns a set of Bazel config flags to set for the build.""" 153 flags = [] 154 155 if self.profile == Profile.RELEASE: 156 # If we're a tagged build, then we'll use stamping to update our 157 # build info, otherwise we'll use our side channel/best-effort 158 # approach to update it. 159 if ui.env_is_truthy("BUILDKITE_TAG"): 160 flags.append("--config=release-tagged") 161 else: 162 flags.append("--config=release-dev") 163 bazel_utils.write_git_hash() 164 elif self.profile == Profile.OPTIMIZED: 165 flags.append("--config=optimized") 166 167 if self.bazel_remote_cache: 168 flags.append(f"--remote_cache={self.bazel_remote_cache}") 169 170 if ui.env_is_truthy("CI"): 171 flags.append("--config=ci") 172 173 return flags 174 175 def rewrite_builder_path_for_host(self, path: Path) -> Path: 176 """Rewrite a path that is relative to the target directory inside the 177 builder to a path that is relative to the target directory on the host. 178 179 If path does is not relative to the target directory inside the builder, 180 it is returned unchanged. 181 """ 182 builder_target_dir = Path("/mnt/build") / xcompile.target(self.arch) 183 try: 184 return self.cargo_target_dir() / path.relative_to(builder_target_dir) 185 except ValueError: 186 return path 187 188 189def docker_images() -> set[str]: 190 """List the Docker images available on the local machine.""" 191 return set( 192 spawn.capture(["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"]) 193 .strip() 194 .split("\n") 195 ) 196 197 198def is_docker_image_pushed(name: str) -> bool: 199 """Check whether the named image is pushed to Docker Hub. 200 201 Note that this operation requires a rather slow network request. 202 """ 203 proc = subprocess.run( 204 ["docker", "manifest", "inspect", name], 205 stdout=subprocess.DEVNULL, 206 stderr=subprocess.DEVNULL, 207 env=dict(os.environ, DOCKER_CLI_EXPERIMENTAL="enabled"), 208 ) 209 return proc.returncode == 0 210 211 212def chmod_x(path: Path) -> None: 213 """Set the executable bit on a file or directory.""" 214 # https://stackoverflow.com/a/30463972/1122351 215 mode = os.stat(path).st_mode 216 mode |= (mode & 0o444) >> 2 # copy R bits to X 217 os.chmod(path, mode) 218 219 220class PreImage: 221 """An action to run before building a Docker image. 222 223 Args: 224 rd: The `RepositoryDetails` for the repository. 225 path: The path to the `Image` associated with this action. 226 """ 227 228 def __init__(self, rd: RepositoryDetails, path: Path): 229 self.rd = rd 230 self.path = path 231 232 @classmethod 233 def prepare_batch(cls, instances: list["PreImage"]) -> Any: 234 """Prepare a batch of actions. 235 236 This is useful for `PreImage` actions that are more efficient when 237 their actions are applied to several images in bulk. 238 239 Returns an arbitrary output that is passed to `PreImage.run`. 240 """ 241 pass 242 243 def run(self, prep: Any) -> None: 244 """Perform the action. 245 246 Args: 247 prep: Any prep work returned by `prepare_batch`. 248 """ 249 pass 250 251 def inputs(self) -> set[str]: 252 """Return the files which are considered inputs to the action.""" 253 raise NotImplementedError 254 255 def extra(self) -> str: 256 """Returns additional data for incorporation in the fingerprint.""" 257 return "" 258 259 260class Copy(PreImage): 261 """A `PreImage` action which copies files from a directory. 262 263 See doc/developer/mzbuild.md for an explanation of the user-facing 264 parameters. 265 """ 266 267 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 268 super().__init__(rd, path) 269 270 self.source = config.pop("source", None) 271 if self.source is None: 272 raise ValueError("mzbuild config is missing 'source' argument") 273 274 self.destination = config.pop("destination", None) 275 if self.destination is None: 276 raise ValueError("mzbuild config is missing 'destination' argument") 277 278 self.matching = config.pop("matching", "*") 279 280 def run(self, prep: Any) -> None: 281 super().run(prep) 282 for src in self.inputs(): 283 dst = self.path / self.destination / src 284 dst.parent.mkdir(parents=True, exist_ok=True) 285 shutil.copy(self.rd.root / self.source / src, dst) 286 287 def inputs(self) -> set[str]: 288 return set(git.expand_globs(self.rd.root / self.source, self.matching)) 289 290 291class CargoPreImage(PreImage): 292 """A `PreImage` action that uses Cargo.""" 293 294 def inputs(self) -> set[str]: 295 inputs = { 296 "ci/builder", 297 "Cargo.toml", 298 # TODO(benesch): we could in theory fingerprint only the subset of 299 # Cargo.lock that applies to the crates at hand, but that is a 300 # *lot* of work. 301 "Cargo.lock", 302 ".cargo/config", 303 # Even though we are not always building with Bazel, consider its 304 # inputs so that developers with CI_BAZEL_BUILD=0 can still 305 # download the images from Dockerhub 306 ".bazelrc", 307 "WORKSPACE", 308 } 309 310 # Bazel has some rules and additive files that aren't directly 311 # associated with a crate, but can change how it's built. 312 additive_path = self.rd.root / "misc" / "bazel" 313 additive_files = ["*.bazel", "*.bzl"] 314 inputs |= { 315 f"misc/bazel/{path}" 316 for path in git.expand_globs(additive_path, *additive_files) 317 } 318 319 return inputs 320 321 def extra(self) -> str: 322 # Cargo images depend on the release mode and whether 323 # coverage/sanitizer is enabled. 324 flags: list[str] = [] 325 if self.rd.profile == Profile.RELEASE: 326 flags += "release" 327 if self.rd.profile == Profile.OPTIMIZED: 328 flags += "optimized" 329 if self.rd.coverage: 330 flags += "coverage" 331 if self.rd.sanitizer != Sanitizer.none: 332 flags += self.rd.sanitizer.value 333 flags.sort() 334 return ",".join(flags) 335 336 337class CargoBuild(CargoPreImage): 338 """A `PreImage` action that builds a single binary with Cargo. 339 340 See doc/developer/mzbuild.md for an explanation of the user-facing 341 parameters. 342 """ 343 344 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 345 super().__init__(rd, path) 346 bin = config.pop("bin", []) 347 self.bins = bin if isinstance(bin, list) else [bin] 348 example = config.pop("example", []) 349 self.examples = example if isinstance(example, list) else [example] 350 self.strip = config.pop("strip", True) 351 self.extract = config.pop("extract", {}) 352 353 bazel_bins = config.pop("bazel-bin") 354 self.bazel_bins = ( 355 bazel_bins if isinstance(bazel_bins, dict) else {self.bins[0]: bazel_bins} 356 ) 357 self.bazel_tars = config.pop("bazel-tar", {}) 358 359 if len(self.bins) == 0 and len(self.examples) == 0: 360 raise ValueError("mzbuild config is missing pre-build target") 361 for bin in self.bins: 362 if bin not in self.bazel_bins: 363 raise ValueError( 364 f"need to specify a 'bazel-bin' for '{bin}' at '{path}'" 365 ) 366 367 @staticmethod 368 def generate_bazel_build_command( 369 rd: RepositoryDetails, 370 bins: list[str], 371 examples: list[str], 372 bazel_bins: dict[str, str], 373 bazel_tars: dict[str, str], 374 ) -> list[str]: 375 assert ( 376 rd.bazel 377 ), "Programming error, tried to invoke Bazel when it is not enabled." 378 assert not rd.coverage, "Bazel doesn't support building with coverage." 379 380 rustflags = [] 381 if rd.sanitizer == Sanitizer.none: 382 rustflags += ["--cfg=tokio_unstable"] 383 384 extra_env = { 385 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 386 } 387 388 bazel_build = rd.build( 389 "build", 390 channel=None, 391 rustflags=rustflags, 392 extra_env=extra_env, 393 ) 394 395 for bin in bins: 396 bazel_build.append(bazel_bins[bin]) 397 for tar in bazel_tars: 398 bazel_build.append(tar) 399 # TODO(parkmycar): Make sure cargo-gazelle generates rust_binary targets for examples. 400 assert len(examples) == 0, "Bazel doesn't support building examples." 401 402 # Add extra Bazel config flags. 403 bazel_build.extend(rd.bazel_config()) 404 # Add flags for the Sanitizer 405 bazel_build.extend(rd.sanitizer.bazel_flags()) 406 407 return bazel_build 408 409 @staticmethod 410 def generate_cargo_build_command( 411 rd: RepositoryDetails, 412 bins: list[str], 413 examples: list[str], 414 ) -> list[str]: 415 assert ( 416 not rd.bazel 417 ), "Programming error, tried to invoke Cargo when Bazel is enabled." 418 419 rustflags = ( 420 rustc_flags.coverage 421 if rd.coverage 422 else ( 423 rustc_flags.sanitizer[rd.sanitizer] 424 if rd.sanitizer != Sanitizer.none 425 else ["--cfg=tokio_unstable"] 426 ) 427 ) 428 cflags = ( 429 [ 430 f"--target={target(rd.arch)}", 431 f"--gcc-toolchain=/opt/x-tools/{target(rd.arch)}/", 432 "-fuse-ld=lld", 433 f"--sysroot=/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/sysroot", 434 f"-L/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/lib64", 435 ] 436 + rustc_flags.sanitizer_cflags[rd.sanitizer] 437 if rd.sanitizer != Sanitizer.none 438 else [] 439 ) 440 extra_env = ( 441 { 442 "CFLAGS": " ".join(cflags), 443 "CXXFLAGS": " ".join(cflags), 444 "LDFLAGS": " ".join(cflags), 445 "CXXSTDLIB": "stdc++", 446 "CC": "cc", 447 "CXX": "c++", 448 "CPP": "clang-cpp-15", 449 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "cc", 450 "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "cc", 451 "PATH": f"/sanshim:/opt/x-tools/{target(rd.arch)}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 452 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 453 } 454 if rd.sanitizer != Sanitizer.none 455 else {} 456 ) 457 458 cargo_build = rd.build( 459 "build", channel=None, rustflags=rustflags, extra_env=extra_env 460 ) 461 462 packages = set() 463 for bin in bins: 464 cargo_build.extend(["--bin", bin]) 465 packages.add(rd.cargo_workspace.crate_for_bin(bin).name) 466 for example in examples: 467 cargo_build.extend(["--example", example]) 468 packages.add(rd.cargo_workspace.crate_for_example(example).name) 469 cargo_build.extend(f"--package={p}" for p in packages) 470 471 if rd.profile == Profile.RELEASE: 472 cargo_build.append("--release") 473 if rd.profile == Profile.OPTIMIZED: 474 cargo_build.extend(["--profile", "optimized"]) 475 if rd.sanitizer != Sanitizer.none: 476 # ASan doesn't work with jemalloc 477 cargo_build.append("--no-default-features") 478 # Uses more memory, so reduce the number of jobs 479 cargo_build.extend( 480 ["--jobs", str(round(multiprocessing.cpu_count() * 2 / 3))] 481 ) 482 483 return cargo_build 484 485 @classmethod 486 def prepare_batch(cls, cargo_builds: list["PreImage"]) -> dict[str, Any]: 487 super().prepare_batch(cargo_builds) 488 489 if not cargo_builds: 490 return {} 491 492 # Building all binaries and examples in the same `cargo build` command 493 # allows Cargo to link in parallel with other work, which can 494 # meaningfully speed up builds. 495 496 rd: RepositoryDetails | None = None 497 builds = cast(list[CargoBuild], cargo_builds) 498 bins = set() 499 examples = set() 500 bazel_bins = dict() 501 bazel_tars = dict() 502 for build in builds: 503 if not rd: 504 rd = build.rd 505 bins.update(build.bins) 506 examples.update(build.examples) 507 bazel_bins.update(build.bazel_bins) 508 bazel_tars.update(build.bazel_tars) 509 assert rd 510 511 ui.section(f"Common build for: {', '.join(bins | examples)}") 512 513 if rd.bazel: 514 cargo_build = cls.generate_bazel_build_command( 515 rd, list(bins), list(examples), bazel_bins, bazel_tars 516 ) 517 else: 518 cargo_build = cls.generate_cargo_build_command( 519 rd, list(bins), list(examples) 520 ) 521 522 spawn.runv(cargo_build, cwd=rd.root) 523 524 # Re-run with JSON-formatted messages and capture the output so we can 525 # later analyze the build artifacts in `run`. This should be nearly 526 # instantaneous since we just compiled above with the same crates and 527 # features. (We don't want to do the compile above with JSON-formatted 528 # messages because it wouldn't be human readable.) 529 if rd.bazel: 530 # TODO(parkmycar): Having to assign the same compilation flags as the build process 531 # is a bit brittle. It would be better if the Bazel build process itself could 532 # output the file to a known location. 533 options = rd.bazel_config() 534 paths_to_binaries = {} 535 for bin in bins: 536 paths = bazel_utils.output_paths(bazel_bins[bin], options) 537 assert len(paths) == 1, f"{bazel_bins[bin]} output more than 1 file" 538 paths_to_binaries[bin] = paths[0] 539 for tar in bazel_tars: 540 paths = bazel_utils.output_paths(tar, options) 541 assert len(paths) == 1, f"more than one output path found for '{tar}'" 542 paths_to_binaries[tar] = paths[0] 543 prep = {"bazel": paths_to_binaries} 544 else: 545 json_output = spawn.capture( 546 cargo_build + ["--message-format=json"], 547 cwd=rd.root, 548 ) 549 prep = {"cargo": json_output} 550 551 return prep 552 553 def build(self, build_output: dict[str, Any]) -> None: 554 cargo_profile = ( 555 "release" 556 if self.rd.profile == Profile.RELEASE 557 else "optimized" if self.rd.profile == Profile.OPTIMIZED else "debug" 558 ) 559 560 def copy(src: Path, relative_dst: Path) -> None: 561 exe_path = self.path / relative_dst 562 exe_path.parent.mkdir(parents=True, exist_ok=True) 563 shutil.copy(src, exe_path) 564 565 # Bazel doesn't add write or exec permissions for built binaries 566 # but `strip` and `objcopy` need write permissions and we add exec 567 # permissions for the built Docker images. 568 current_perms = os.stat(exe_path).st_mode 569 new_perms = ( 570 current_perms 571 # chmod +wx 572 | stat.S_IWUSR 573 | stat.S_IWGRP 574 | stat.S_IWOTH 575 | stat.S_IXUSR 576 | stat.S_IXGRP 577 | stat.S_IXOTH 578 ) 579 os.chmod(exe_path, new_perms) 580 581 if self.strip: 582 # The debug information is large enough that it slows down CI, 583 # since we're packaging these binaries up into Docker images and 584 # shipping them around. 585 spawn.runv( 586 [*self.rd.tool("strip"), "--strip-debug", exe_path], 587 cwd=self.rd.root, 588 ) 589 else: 590 # Even if we've been asked not to strip the binary, remove the 591 # `.debug_pubnames` and `.debug_pubtypes` sections. These are just 592 # indexes that speed up launching a debugger against the binary, 593 # and we're happy to have slower debugger start up in exchange for 594 # smaller binaries. Plus the sections have been obsoleted by a 595 # `.debug_names` section in DWARF 5, and so debugger support for 596 # `.debug_pubnames`/`.debug_pubtypes` is minimal anyway. 597 # See: https://github.com/rust-lang/rust/issues/46034 598 spawn.runv( 599 [ 600 *self.rd.tool("objcopy"), 601 "-R", 602 ".debug_pubnames", 603 "-R", 604 ".debug_pubtypes", 605 exe_path, 606 ], 607 cwd=self.rd.root, 608 ) 609 610 for bin in self.bins: 611 if "bazel" in build_output: 612 src_path = self.rd.bazel_workspace_dir() / build_output["bazel"][bin] 613 else: 614 src_path = self.rd.cargo_target_dir() / cargo_profile / bin 615 copy(src_path, bin) 616 for example in self.examples: 617 src_path = ( 618 self.rd.cargo_target_dir() / cargo_profile / Path("examples") / example 619 ) 620 copy(src_path, Path("examples") / example) 621 622 # Bazel doesn't support 'extract', instead you need to use 'bazel-tar' 623 if self.extract and "bazel" not in build_output: 624 cargo_build_json_output = build_output["cargo"] 625 626 target_dir = self.rd.cargo_target_dir() 627 for line in cargo_build_json_output.split("\n"): 628 if line.strip() == "" or not line.startswith("{"): 629 continue 630 message = json.loads(line) 631 if message["reason"] != "build-script-executed": 632 continue 633 out_dir = self.rd.rewrite_builder_path_for_host( 634 Path(message["out_dir"]) 635 ) 636 if not out_dir.is_relative_to(target_dir): 637 # Some crates are built for both the host and the target. 638 # Ignore the built-for-host out dir. 639 continue 640 # parse the package name from a package_id that looks like one of: 641 # git+https://github.com/MaterializeInc/rust-server-sdk#launchdarkly-server-sdk@1.0.0 642 # path+file:///Users/roshan/materialize/src/catalog#mz-catalog@0.0.0 643 # registry+https://github.com/rust-lang/crates.io-index#num-rational@0.4.0 644 # file:///path/to/my-package#0.1.0 645 package_id = message["package_id"] 646 if "@" in package_id: 647 package = package_id.split("@")[0].split("#")[-1] 648 else: 649 package = message["package_id"].split("#")[0].split("/")[-1] 650 for src, dst in self.extract.get(package, {}).items(): 651 spawn.runv(["cp", "-R", out_dir / src, self.path / dst]) 652 653 if self.bazel_tars and "bazel" in build_output: 654 ui.section("Extracing 'bazel-tar'") 655 for tar in self.bazel_tars: 656 # Where Bazel built the tarball. 657 tar_path = self.rd.bazel_workspace_dir() / build_output["bazel"][tar] 658 # Where we need to extract it into. 659 tar_dest = self.path / self.bazel_tars[tar] 660 ui.say(f"extracing {tar_path} to {tar_dest}") 661 662 with tarfile.open(tar_path, "r") as tar_file: 663 os.makedirs(tar_dest, exist_ok=True) 664 tar_file.extractall(path=tar_dest) 665 666 self.acquired = True 667 668 def run(self, prep: dict[str, Any]) -> None: 669 super().run(prep) 670 self.build(prep) 671 672 def inputs(self) -> set[str]: 673 deps = set() 674 675 for bin in self.bins: 676 crate = self.rd.cargo_workspace.crate_for_bin(bin) 677 deps |= self.rd.cargo_workspace.transitive_path_dependencies(crate) 678 for example in self.examples: 679 crate = self.rd.cargo_workspace.crate_for_example(example) 680 deps |= self.rd.cargo_workspace.transitive_path_dependencies( 681 crate, dev=True 682 ) 683 inputs = super().inputs() | set(inp for dep in deps for inp in dep.inputs()) 684 # Even though we are not always building with Bazel, consider its 685 # inputs so that developers with CI_BAZEL_BUILD=0 can still 686 # download the images from Dockerhub 687 inputs |= {"BUILD.bazel"} 688 689 return inputs 690 691 692class Image: 693 """A Docker image whose build and dependencies are managed by mzbuild. 694 695 An image corresponds to a directory in a repository that contains a 696 `mzbuild.yml` file. This directory is called an "mzbuild context." 697 698 Attributes: 699 name: The name of the image. 700 publish: Whether the image should be pushed to Docker Hub. 701 depends_on: The names of the images upon which this image depends. 702 root: The path to the root of the associated `Repository`. 703 path: The path to the directory containing the `mzbuild.yml` 704 configuration file. 705 pre_images: Optional actions to perform before running `docker build`. 706 build_args: An optional list of --build-arg to pass to the dockerfile 707 """ 708 709 _DOCKERFILE_MZFROM_RE = re.compile(rb"^MZFROM\s*(\S+)") 710 711 def __init__(self, rd: RepositoryDetails, path: Path): 712 self.rd = rd 713 self.path = path 714 self.pre_images: list[PreImage] = [] 715 with open(self.path / "mzbuild.yml") as f: 716 data = yaml.safe_load(f) 717 self.name: str = data.pop("name") 718 self.publish: bool = data.pop("publish", True) 719 self.description: str | None = data.pop("description", None) 720 self.mainline: bool = data.pop("mainline", True) 721 for pre_image in data.pop("pre-image", []): 722 typ = pre_image.pop("type", None) 723 if typ == "cargo-build": 724 self.pre_images.append(CargoBuild(self.rd, self.path, pre_image)) 725 elif typ == "copy": 726 self.pre_images.append(Copy(self.rd, self.path, pre_image)) 727 else: 728 raise ValueError( 729 f"mzbuild config in {self.path} has unknown pre-image type" 730 ) 731 self.build_args = data.pop("build-args", {}) 732 733 if re.search(r"[^A-Za-z0-9\-]", self.name): 734 raise ValueError( 735 f"mzbuild image name {self.name} contains invalid character; only alphanumerics and hyphens allowed" 736 ) 737 738 self.depends_on: list[str] = [] 739 with open(self.path / "Dockerfile", "rb") as f: 740 for line in f: 741 match = self._DOCKERFILE_MZFROM_RE.match(line) 742 if match: 743 self.depends_on.append(match.group(1).decode()) 744 745 def sync_description(self) -> None: 746 """Sync the description to Docker Hub if the image is publishable 747 and a README.md file exists.""" 748 749 if not self.publish: 750 ui.say(f"{self.name} is not publishable") 751 return 752 753 readme_path = self.path / "README.md" 754 has_readme = readme_path.exists() 755 if not has_readme: 756 ui.say(f"{self.name} has no README.md or description") 757 return 758 759 docker_config = os.getenv("DOCKER_CONFIG") 760 spawn.runv( 761 [ 762 "docker", 763 "pushrm", 764 f"--file={readme_path}", 765 *([f"--config={docker_config}/config.json"] if docker_config else []), 766 *([f"--short={self.description}"] if self.description else []), 767 self.docker_name(), 768 ] 769 ) 770 771 def docker_name(self, tag: str | None = None) -> str: 772 """Return the name of the image on Docker Hub at the given tag.""" 773 name = f"{self.rd.image_registry}/{self.rd.image_prefix}{self.name}" 774 if tag: 775 name += f":{tag}" 776 return name 777 778 779class ResolvedImage: 780 """An `Image` whose dependencies have been resolved. 781 782 Attributes: 783 image: The underlying `Image`. 784 acquired: Whether the image is available locally. 785 dependencies: A mapping from dependency name to `ResolvedImage` for 786 each of the images that `image` depends upon. 787 """ 788 789 def __init__(self, image: Image, dependencies: Iterable["ResolvedImage"]): 790 self.image = image 791 self.acquired = False 792 self.dependencies = {} 793 for d in dependencies: 794 self.dependencies[d.name] = d 795 796 def __repr__(self) -> str: 797 return f"ResolvedImage<{self.spec()}>" 798 799 @property 800 def name(self) -> str: 801 """The name of the underlying image.""" 802 return self.image.name 803 804 @property 805 def publish(self) -> bool: 806 """Whether the underlying image should be pushed to Docker Hub.""" 807 return self.image.publish 808 809 def spec(self) -> str: 810 """Return the "spec" for the image. 811 812 A spec is the unique identifier for the image given its current 813 fingerprint. It is a valid Docker Hub name. 814 """ 815 return self.image.docker_name(tag=f"mzbuild-{self.fingerprint()}") 816 817 def write_dockerfile(self) -> IO[bytes]: 818 """Render the Dockerfile without mzbuild directives. 819 820 Returns: 821 file: A handle to a temporary file containing the adjusted 822 Dockerfile.""" 823 with open(self.image.path / "Dockerfile", "rb") as f: 824 lines = f.readlines() 825 f = TemporaryFile() 826 for line in lines: 827 match = Image._DOCKERFILE_MZFROM_RE.match(line) 828 if match: 829 image = match.group(1).decode() 830 spec = self.dependencies[image].spec() 831 line = Image._DOCKERFILE_MZFROM_RE.sub(b"FROM %b" % spec.encode(), line) 832 f.write(line) 833 f.seek(0) 834 return f 835 836 def build(self, prep: dict[type[PreImage], Any]) -> None: 837 """Build the image from source. 838 839 Requires that the caller has already acquired all dependencies and 840 prepared all `PreImage` actions via `PreImage.prepare_batch`. 841 """ 842 ui.section(f"Building {self.spec()}") 843 spawn.runv(["git", "clean", "-ffdX", self.image.path]) 844 845 for pre_image in self.image.pre_images: 846 pre_image.run(prep[type(pre_image)]) 847 build_args = { 848 **self.image.build_args, 849 "ARCH_GCC": str(self.image.rd.arch), 850 "ARCH_GO": self.image.rd.arch.go_str(), 851 "CI_SANITIZER": str(self.image.rd.sanitizer), 852 } 853 f = self.write_dockerfile() 854 cmd: Sequence[str] = [ 855 "docker", 856 "build", 857 "-f", 858 "-", 859 *(f"--build-arg={k}={v}" for k, v in build_args.items()), 860 "-t", 861 self.spec(), 862 f"--platform=linux/{self.image.rd.arch.go_str()}", 863 str(self.image.path), 864 ] 865 spawn.runv(cmd, stdin=f, stdout=sys.stderr.buffer) 866 867 def try_pull(self, max_retries: int) -> bool: 868 """Download the image if it does not exist locally. Returns whether it was found.""" 869 ui.header(f"Acquiring {self.spec()}") 870 command = ["docker", "pull"] 871 # --quiet skips printing the progress bar, which does not display well in CI. 872 if ui.env_is_truthy("CI"): 873 command.append("--quiet") 874 command.append(self.spec()) 875 if not self.acquired: 876 sleep_time = 1 877 for retry in range(1, max_retries + 1): 878 try: 879 spawn.runv( 880 command, 881 stdout=sys.stderr.buffer, 882 ) 883 self.acquired = True 884 except subprocess.CalledProcessError: 885 if retry < max_retries: 886 # There seems to be no good way to tell what error 887 # happened based on error code 888 # (https://github.com/docker/cli/issues/538) and we 889 # want to print output directly to terminal. 890 print(f"Retrying in {sleep_time}s ...") 891 time.sleep(sleep_time) 892 sleep_time *= 2 893 continue 894 else: 895 break 896 return self.acquired 897 898 def is_published_if_necessary(self) -> bool: 899 """Report whether the image exists on Docker Hub if it is publishable.""" 900 if self.publish and is_docker_image_pushed(self.spec()): 901 ui.say(f"{self.spec()} already exists") 902 return True 903 return False 904 905 def run( 906 self, 907 args: list[str] = [], 908 docker_args: list[str] = [], 909 env: dict[str, str] = {}, 910 ) -> None: 911 """Run a command in the image. 912 913 Creates a container from the image and runs the command described by 914 `args` in the image. 915 """ 916 envs = [] 917 for key, val in env.items(): 918 envs.extend(["--env", f"{key}={val}"]) 919 spawn.runv( 920 [ 921 "docker", 922 "run", 923 "--tty", 924 "--rm", 925 *envs, 926 "--init", 927 *docker_args, 928 self.spec(), 929 *args, 930 ], 931 ) 932 933 def list_dependencies(self, transitive: bool = False) -> set[str]: 934 out = set() 935 for dep in self.dependencies.values(): 936 out.add(dep.name) 937 if transitive: 938 out |= dep.list_dependencies(transitive) 939 return out 940 941 def inputs(self, transitive: bool = False) -> set[str]: 942 """List the files tracked as inputs to the image. 943 944 These files are used to compute the fingerprint for the image. See 945 `ResolvedImage.fingerprint` for details. 946 947 Returns: 948 inputs: A list of input files, relative to the root of the 949 repository. 950 """ 951 paths = set(git.expand_globs(self.image.rd.root, f"{self.image.path}/**")) 952 if not paths: 953 # While we could find an `mzbuild.yml` file for this service, expland_globs didn't 954 # return any files that matched this service. At the very least, the `mzbuild.yml` 955 # file itself should have been returned. We have a bug if paths is empty. 956 raise AssertionError( 957 f"{self.image.name} mzbuild exists but its files are unknown to git" 958 ) 959 for pre_image in self.image.pre_images: 960 paths |= pre_image.inputs() 961 if transitive: 962 for dep in self.dependencies.values(): 963 paths |= dep.inputs(transitive) 964 return paths 965 966 @cache 967 def fingerprint(self) -> Fingerprint: 968 """Fingerprint the inputs to the image. 969 970 Compute the fingerprint of the image. Changing the contents of any of 971 the files or adding or removing files to the image will change the 972 fingerprint, as will modifying the inputs to any of its dependencies. 973 974 The image considers all non-gitignored files in its mzbuild context to 975 be inputs. If it has a pre-image action, that action may add additional 976 inputs via `PreImage.inputs`. 977 """ 978 self_hash = hashlib.sha1() 979 for rel_path in sorted( 980 set(git.expand_globs(self.image.rd.root, *self.inputs())) 981 ): 982 abs_path = self.image.rd.root / rel_path 983 file_hash = hashlib.sha1() 984 raw_file_mode = os.lstat(abs_path).st_mode 985 # Compute a simplified file mode using the same rules as Git. 986 # https://github.com/git/git/blob/3bab5d562/Documentation/git-fast-import.txt#L610-L616 987 if stat.S_ISLNK(raw_file_mode): 988 file_mode = 0o120000 989 elif raw_file_mode & stat.S_IXUSR: 990 file_mode = 0o100755 991 else: 992 file_mode = 0o100644 993 with open(abs_path, "rb") as f: 994 file_hash.update(f.read()) 995 self_hash.update(file_mode.to_bytes(2, byteorder="big")) 996 self_hash.update(rel_path.encode()) 997 self_hash.update(file_hash.digest()) 998 self_hash.update(b"\0") 999 1000 for pre_image in self.image.pre_images: 1001 self_hash.update(pre_image.extra().encode()) 1002 self_hash.update(b"\0") 1003 1004 self_hash.update(f"arch={self.image.rd.arch}".encode()) 1005 self_hash.update(f"coverage={self.image.rd.coverage}".encode()) 1006 self_hash.update(f"sanitizer={self.image.rd.sanitizer}".encode()) 1007 1008 full_hash = hashlib.sha1() 1009 full_hash.update(self_hash.digest()) 1010 for dep in sorted(self.dependencies.values(), key=lambda d: d.name): 1011 full_hash.update(dep.name.encode()) 1012 full_hash.update(dep.fingerprint()) 1013 full_hash.update(b"\0") 1014 1015 return Fingerprint(full_hash.digest()) 1016 1017 1018class DependencySet: 1019 """A set of `ResolvedImage`s. 1020 1021 Iterating over a dependency set yields the contained images in an arbitrary 1022 order. Indexing a dependency set yields the image with the specified name. 1023 """ 1024 1025 def __init__(self, dependencies: Iterable[Image]): 1026 """Construct a new `DependencySet`. 1027 1028 The provided `dependencies` must be topologically sorted. 1029 """ 1030 self._dependencies: dict[str, ResolvedImage] = {} 1031 known_images = docker_images() 1032 for d in dependencies: 1033 image = ResolvedImage( 1034 image=d, 1035 dependencies=(self._dependencies[d0] for d0 in d.depends_on), 1036 ) 1037 image.acquired = image.spec() in known_images 1038 self._dependencies[d.name] = image 1039 1040 def _prepare_batch(self, images: list[ResolvedImage]) -> dict[type[PreImage], Any]: 1041 pre_images = collections.defaultdict(list) 1042 for image in images: 1043 for pre_image in image.image.pre_images: 1044 pre_images[type(pre_image)].append(pre_image) 1045 pre_image_prep = {} 1046 for cls, instances in pre_images.items(): 1047 pre_image = cast(PreImage, cls) 1048 pre_image_prep[cls] = pre_image.prepare_batch(instances) 1049 return pre_image_prep 1050 1051 def acquire(self, max_retries: int | None = None) -> None: 1052 """Download or build all of the images in the dependency set that do not 1053 already exist locally. 1054 1055 Args: 1056 max_retries: Number of retries on failure. 1057 """ 1058 1059 # Only retry in CI runs since we struggle with flaky docker pulls there 1060 if not max_retries: 1061 max_retries = 5 if ui.env_is_truthy("CI") else 1 1062 assert max_retries > 0 1063 1064 deps_to_build = [ 1065 dep for dep in self if not dep.publish or not dep.try_pull(max_retries) 1066 ] 1067 1068 # Don't attempt to build in CI, as our timeouts and small machines won't allow it anyway 1069 if ui.env_is_truthy("CI"): 1070 expected_deps = [dep for dep in deps_to_build if dep.publish] 1071 if expected_deps: 1072 print( 1073 f"+++ Expected builds to be available, the build probably failed, so not proceeding: {expected_deps}" 1074 ) 1075 sys.exit(5) 1076 1077 prep = self._prepare_batch(deps_to_build) 1078 for dep in deps_to_build: 1079 dep.build(prep) 1080 1081 def ensure(self, post_build: Callable[[ResolvedImage], None] | None = None): 1082 """Ensure all publishable images in this dependency set exist on Docker 1083 Hub. 1084 1085 Images are pushed using their spec as their tag. 1086 1087 Args: 1088 post_build: A callback to invoke with each dependency that was built 1089 locally. 1090 """ 1091 deps_to_build = [dep for dep in self if not dep.is_published_if_necessary()] 1092 prep = self._prepare_batch(deps_to_build) 1093 1094 images_to_push = [] 1095 for dep in deps_to_build: 1096 dep.build(prep) 1097 if post_build: 1098 post_build(dep) 1099 if dep.publish: 1100 images_to_push.append(dep.spec()) 1101 1102 # Push all Docker images in parallel to minimize build time. 1103 ui.section("Pushing images") 1104 # Attempt to upload images a maximum of 3 times before giving up. 1105 for attempts_remaining in reversed(range(3)): 1106 pushes: list[subprocess.Popen] = [] 1107 for image in images_to_push: 1108 # Piping through `cat` disables terminal control codes, and so the 1109 # interleaved progress output from multiple pushes is less hectic. 1110 # We don't use `docker push --quiet`, as that disables progress 1111 # output entirely. Use `set -o pipefail` so the return code of 1112 # `docker push` is passed through. 1113 push = subprocess.Popen( 1114 [ 1115 "/bin/bash", 1116 "-c", 1117 f"set -o pipefail; docker push {shlex.quote(image)} | cat", 1118 ] 1119 ) 1120 pushes.append(push) 1121 1122 for i, push in reversed(list(enumerate(pushes))): 1123 returncode = push.wait() 1124 if returncode: 1125 if attempts_remaining == 0: 1126 # Last attempt, fail 1127 raise subprocess.CalledProcessError(returncode, push.args) 1128 else: 1129 print(f"docker push {push.args} failed: {returncode}") 1130 else: 1131 del images_to_push[i] 1132 1133 if images_to_push: 1134 time.sleep(10) 1135 print("Retrying in 10 seconds") 1136 1137 def check(self) -> bool: 1138 """Check all publishable images in this dependency set exist on Docker 1139 Hub. Don't try to download or build them.""" 1140 num_deps = len(list(self)) 1141 if num_deps == 0: 1142 return True 1143 with ThreadPoolExecutor(max_workers=num_deps) as executor: 1144 results = list( 1145 executor.map(lambda dep: dep.is_published_if_necessary(), list(self)) 1146 ) 1147 return all(results) 1148 1149 def __iter__(self) -> Iterator[ResolvedImage]: 1150 return iter(self._dependencies.values()) 1151 1152 def __getitem__(self, key: str) -> ResolvedImage: 1153 return self._dependencies[key] 1154 1155 1156class Repository: 1157 """A collection of mzbuild `Image`s. 1158 1159 Creating a repository will walk the filesystem beneath `root` to 1160 automatically discover all contained `Image`s. 1161 1162 Iterating over a repository yields the contained images in an arbitrary 1163 order. 1164 1165 Args: 1166 root: The path to the root of the repository. 1167 arch: The CPU architecture to build for. 1168 profile: What profile to build the repository in. 1169 coverage: Whether to enable code coverage instrumentation. 1170 sanitizer: Whether to a sanitizer (address, thread, leak, memory, none) 1171 image_registry: The Docker image registry to pull images from and push 1172 images to. 1173 image_prefix: A prefix to apply to all Docker image names. 1174 1175 Attributes: 1176 images: A mapping from image name to `Image` for all contained images. 1177 compose_dirs: The set of directories containing a `mzcompose.py` file. 1178 """ 1179 1180 def __init__( 1181 self, 1182 root: Path, 1183 arch: Arch = Arch.host(), 1184 profile: Profile = Profile.RELEASE, 1185 coverage: bool = False, 1186 sanitizer: Sanitizer = Sanitizer.none, 1187 image_registry: str = "materialize", 1188 image_prefix: str = "", 1189 bazel: bool = False, 1190 bazel_remote_cache: str | None = None, 1191 ): 1192 self.rd = RepositoryDetails( 1193 root, 1194 arch, 1195 profile, 1196 coverage, 1197 sanitizer, 1198 image_registry, 1199 image_prefix, 1200 bazel, 1201 bazel_remote_cache, 1202 ) 1203 self.images: dict[str, Image] = {} 1204 self.compositions: dict[str, Path] = {} 1205 for path, dirs, files in os.walk(self.root, topdown=True): 1206 if path == str(root / "misc"): 1207 dirs.remove("python") 1208 # Filter out some particularly massive ignored directories to keep 1209 # things snappy. Not required for correctness. 1210 dirs[:] = set(dirs) - { 1211 ".git", 1212 ".mypy_cache", 1213 "target", 1214 "target-ra", 1215 "target-xcompile", 1216 "mzdata", 1217 "node_modules", 1218 "venv", 1219 } 1220 if "mzbuild.yml" in files: 1221 image = Image(self.rd, Path(path)) 1222 if not image.name: 1223 raise ValueError(f"config at {path} missing name") 1224 if image.name in self.images: 1225 raise ValueError(f"image {image.name} exists twice") 1226 self.images[image.name] = image 1227 if "mzcompose.py" in files: 1228 name = Path(path).name 1229 if name in self.compositions: 1230 raise ValueError(f"composition {name} exists twice") 1231 self.compositions[name] = Path(path) 1232 1233 # Validate dependencies. 1234 for image in self.images.values(): 1235 for d in image.depends_on: 1236 if d not in self.images: 1237 raise ValueError( 1238 f"image {image.name} depends on non-existent image {d}" 1239 ) 1240 1241 @staticmethod 1242 def install_arguments(parser: argparse.ArgumentParser) -> None: 1243 """Install options to configure a repository into an argparse parser. 1244 1245 This function installs the following options: 1246 1247 * The mutually-exclusive `--dev`/`--optimized`/`--release` options to control the 1248 `profile` repository attribute. 1249 * The `--coverage` boolean option to control the `coverage` repository 1250 attribute. 1251 1252 Use `Repository.from_arguments` to construct a repository from the 1253 parsed command-line arguments. 1254 """ 1255 build_mode = parser.add_mutually_exclusive_group() 1256 build_mode.add_argument( 1257 "--dev", 1258 action="store_true", 1259 help="build Rust binaries with the dev profile", 1260 ) 1261 build_mode.add_argument( 1262 "--release", 1263 action="store_true", 1264 help="build Rust binaries with the release profile (default)", 1265 ) 1266 build_mode.add_argument( 1267 "--optimized", 1268 action="store_true", 1269 help="build Rust binaries with the optimized profile (optimizations, no LTO, no debug symbols)", 1270 ) 1271 parser.add_argument( 1272 "--coverage", 1273 help="whether to enable code coverage compilation flags", 1274 default=ui.env_is_truthy("CI_COVERAGE_ENABLED"), 1275 action="store_true", 1276 ) 1277 parser.add_argument( 1278 "--sanitizer", 1279 help="whether to enable a sanitizer", 1280 default=Sanitizer[os.getenv("CI_SANITIZER", "none")], 1281 type=Sanitizer, 1282 choices=Sanitizer, 1283 ) 1284 parser.add_argument( 1285 "--arch", 1286 default=Arch.host(), 1287 help="the CPU architecture to build for", 1288 type=Arch, 1289 choices=Arch, 1290 ) 1291 parser.add_argument( 1292 "--image-registry", 1293 default="materialize", 1294 help="the Docker image registry to pull images from and push images to", 1295 ) 1296 parser.add_argument( 1297 "--image-prefix", 1298 default="", 1299 help="a prefix to apply to all Docker image names", 1300 ) 1301 parser.add_argument( 1302 "--bazel", 1303 default=ui.env_is_truthy("CI_BAZEL_BUILD"), 1304 action="store_true", 1305 ) 1306 parser.add_argument( 1307 "--bazel-remote-cache", 1308 default=os.getenv("CI_BAZEL_REMOTE_CACHE"), 1309 action="store", 1310 ) 1311 1312 @classmethod 1313 def from_arguments(cls, root: Path, args: argparse.Namespace) -> "Repository": 1314 """Construct a repository from command-line arguments. 1315 1316 The provided namespace must contain the options installed by 1317 `Repository.install_arguments`. 1318 """ 1319 if args.release: 1320 profile = Profile.RELEASE 1321 elif args.optimized: 1322 profile = Profile.OPTIMIZED 1323 elif args.dev: 1324 profile = Profile.DEV 1325 else: 1326 profile = Profile.RELEASE 1327 1328 return cls( 1329 root, 1330 profile=profile, 1331 coverage=args.coverage, 1332 sanitizer=args.sanitizer, 1333 image_registry=args.image_registry, 1334 image_prefix=args.image_prefix, 1335 arch=args.arch, 1336 bazel=args.bazel, 1337 bazel_remote_cache=args.bazel_remote_cache, 1338 ) 1339 1340 @property 1341 def root(self) -> Path: 1342 """The path to the root directory for the repository.""" 1343 return self.rd.root 1344 1345 def resolve_dependencies(self, targets: Iterable[Image]) -> DependencySet: 1346 """Compute the dependency set necessary to build target images. 1347 1348 The dependencies of `targets` will be crawled recursively until the 1349 complete set of transitive dependencies is determined or a circular 1350 dependency is discovered. The returned dependency set will be sorted 1351 in topological order. 1352 1353 Raises: 1354 ValueError: A circular dependency was discovered in the images 1355 in the repository. 1356 """ 1357 resolved = OrderedDict() 1358 visiting = set() 1359 1360 def visit(image: Image, path: list[str] = []) -> None: 1361 if image.name in resolved: 1362 return 1363 if image.name in visiting: 1364 diagram = " -> ".join(path + [image.name]) 1365 raise ValueError(f"circular dependency in mzbuild: {diagram}") 1366 1367 visiting.add(image.name) 1368 for d in sorted(image.depends_on): 1369 visit(self.images[d], path + [image.name]) 1370 resolved[image.name] = image 1371 1372 for target_image in sorted(targets, key=lambda image: image.name): 1373 visit(target_image) 1374 1375 return DependencySet(resolved.values()) 1376 1377 def __iter__(self) -> Iterator[Image]: 1378 return iter(self.images.values()) 1379 1380 1381def publish_multiarch_images( 1382 tag: str, dependency_sets: Iterable[Iterable[ResolvedImage]] 1383) -> None: 1384 """Publishes a set of docker images under a given tag.""" 1385 for images in zip(*dependency_sets): 1386 names = set(image.image.name for image in images) 1387 assert len(names) == 1, "dependency sets did not contain identical images" 1388 name = images[0].image.docker_name(tag) 1389 spawn.runv( 1390 ["docker", "manifest", "create", name, *(image.spec() for image in images)] 1391 ) 1392 spawn.runv(["docker", "manifest", "push", name]) 1393 print(f"--- Nofifying for tag {tag}") 1394 markdown = f"""Pushed images with Docker tag `{tag}`""" 1395 spawn.runv( 1396 [ 1397 "buildkite-agent", 1398 "annotate", 1399 "--style=info", 1400 f"--context=build-tags-{tag}", 1401 ], 1402 stdin=markdown.encode(), 1403 )
51class Fingerprint(bytes): 52 """A SHA-1 hash of the inputs to an `Image`. 53 54 The string representation uses base32 encoding to distinguish mzbuild 55 fingerprints from Git's hex encoded SHA-1 hashes while still being 56 URL safe. 57 """ 58 59 def __str__(self) -> str: 60 return base64.b32encode(self).decode()
A SHA-1 hash of the inputs to an Image
.
The string representation uses base32 encoding to distinguish mzbuild fingerprints from Git's hex encoded SHA-1 hashes while still being URL safe.
69class RepositoryDetails: 70 """Immutable details about a `Repository`. 71 72 Used internally by mzbuild. 73 74 Attributes: 75 root: The path to the root of the repository. 76 arch: The CPU architecture to build for. 77 profile: What profile the repository is being built with. 78 coverage: Whether the repository has code coverage instrumentation 79 enabled. 80 sanitizer: Whether to use a sanitizer (address, hwaddress, cfi, thread, leak, memory, none) 81 cargo_workspace: The `cargo.Workspace` associated with the repository. 82 image_registry: The Docker image registry to pull images from and push 83 images to. 84 image_prefix: A prefix to apply to all Docker image names. 85 bazel: Whether or not to use Bazel as the build system instead of Cargo. 86 bazel_remote_cache: URL of a Bazel Remote Cache that we can build with. 87 """ 88 89 def __init__( 90 self, 91 root: Path, 92 arch: Arch, 93 profile: Profile, 94 coverage: bool, 95 sanitizer: Sanitizer, 96 image_registry: str, 97 image_prefix: str, 98 bazel: bool, 99 bazel_remote_cache: str | None, 100 ): 101 self.root = root 102 self.arch = arch 103 self.profile = profile 104 self.coverage = coverage 105 self.sanitizer = sanitizer 106 self.cargo_workspace = cargo.Workspace(root) 107 self.image_registry = image_registry 108 self.image_prefix = image_prefix 109 self.bazel = bazel 110 self.bazel_remote_cache = bazel_remote_cache 111 112 def build( 113 self, 114 subcommand: str, 115 rustflags: list[str], 116 channel: str | None = None, 117 extra_env: dict[str, str] = {}, 118 ) -> list[str]: 119 """Start a build invocation for the configured architecture.""" 120 if self.bazel: 121 assert not channel, "Bazel doesn't support building for multiple channels." 122 return xcompile.bazel( 123 arch=self.arch, 124 subcommand=subcommand, 125 rustflags=rustflags, 126 extra_env=extra_env, 127 ) 128 else: 129 return xcompile.cargo( 130 arch=self.arch, 131 channel=channel, 132 subcommand=subcommand, 133 rustflags=rustflags, 134 extra_env=extra_env, 135 ) 136 137 def tool(self, name: str) -> list[str]: 138 """Start a binutils tool invocation for the configured architecture.""" 139 if self.bazel: 140 return ["bazel", "run", f"@//misc/bazel/tools:{name}", "--"] 141 else: 142 return xcompile.tool(self.arch, name) 143 144 def cargo_target_dir(self) -> Path: 145 """Determine the path to the target directory for Cargo.""" 146 return self.root / "target-xcompile" / xcompile.target(self.arch) 147 148 def bazel_workspace_dir(self) -> Path: 149 """Determine the path to the root of the Bazel workspace.""" 150 return self.root 151 152 def bazel_config(self) -> list[str]: 153 """Returns a set of Bazel config flags to set for the build.""" 154 flags = [] 155 156 if self.profile == Profile.RELEASE: 157 # If we're a tagged build, then we'll use stamping to update our 158 # build info, otherwise we'll use our side channel/best-effort 159 # approach to update it. 160 if ui.env_is_truthy("BUILDKITE_TAG"): 161 flags.append("--config=release-tagged") 162 else: 163 flags.append("--config=release-dev") 164 bazel_utils.write_git_hash() 165 elif self.profile == Profile.OPTIMIZED: 166 flags.append("--config=optimized") 167 168 if self.bazel_remote_cache: 169 flags.append(f"--remote_cache={self.bazel_remote_cache}") 170 171 if ui.env_is_truthy("CI"): 172 flags.append("--config=ci") 173 174 return flags 175 176 def rewrite_builder_path_for_host(self, path: Path) -> Path: 177 """Rewrite a path that is relative to the target directory inside the 178 builder to a path that is relative to the target directory on the host. 179 180 If path does is not relative to the target directory inside the builder, 181 it is returned unchanged. 182 """ 183 builder_target_dir = Path("/mnt/build") / xcompile.target(self.arch) 184 try: 185 return self.cargo_target_dir() / path.relative_to(builder_target_dir) 186 except ValueError: 187 return path
Immutable details about a Repository
.
Used internally by mzbuild.
Attributes:
root: The path to the root of the repository.
arch: The CPU architecture to build for.
profile: What profile the repository is being built with.
coverage: Whether the repository has code coverage instrumentation
enabled.
sanitizer: Whether to use a sanitizer (address, hwaddress, cfi, thread, leak, memory, none)
cargo_workspace: The cargo.Workspace
associated with the repository.
image_registry: The Docker image registry to pull images from and push
images to.
image_prefix: A prefix to apply to all Docker image names.
bazel: Whether or not to use Bazel as the build system instead of Cargo.
bazel_remote_cache: URL of a Bazel Remote Cache that we can build with.
89 def __init__( 90 self, 91 root: Path, 92 arch: Arch, 93 profile: Profile, 94 coverage: bool, 95 sanitizer: Sanitizer, 96 image_registry: str, 97 image_prefix: str, 98 bazel: bool, 99 bazel_remote_cache: str | None, 100 ): 101 self.root = root 102 self.arch = arch 103 self.profile = profile 104 self.coverage = coverage 105 self.sanitizer = sanitizer 106 self.cargo_workspace = cargo.Workspace(root) 107 self.image_registry = image_registry 108 self.image_prefix = image_prefix 109 self.bazel = bazel 110 self.bazel_remote_cache = bazel_remote_cache
112 def build( 113 self, 114 subcommand: str, 115 rustflags: list[str], 116 channel: str | None = None, 117 extra_env: dict[str, str] = {}, 118 ) -> list[str]: 119 """Start a build invocation for the configured architecture.""" 120 if self.bazel: 121 assert not channel, "Bazel doesn't support building for multiple channels." 122 return xcompile.bazel( 123 arch=self.arch, 124 subcommand=subcommand, 125 rustflags=rustflags, 126 extra_env=extra_env, 127 ) 128 else: 129 return xcompile.cargo( 130 arch=self.arch, 131 channel=channel, 132 subcommand=subcommand, 133 rustflags=rustflags, 134 extra_env=extra_env, 135 )
Start a build invocation for the configured architecture.
137 def tool(self, name: str) -> list[str]: 138 """Start a binutils tool invocation for the configured architecture.""" 139 if self.bazel: 140 return ["bazel", "run", f"@//misc/bazel/tools:{name}", "--"] 141 else: 142 return xcompile.tool(self.arch, name)
Start a binutils tool invocation for the configured architecture.
144 def cargo_target_dir(self) -> Path: 145 """Determine the path to the target directory for Cargo.""" 146 return self.root / "target-xcompile" / xcompile.target(self.arch)
Determine the path to the target directory for Cargo.
148 def bazel_workspace_dir(self) -> Path: 149 """Determine the path to the root of the Bazel workspace.""" 150 return self.root
Determine the path to the root of the Bazel workspace.
152 def bazel_config(self) -> list[str]: 153 """Returns a set of Bazel config flags to set for the build.""" 154 flags = [] 155 156 if self.profile == Profile.RELEASE: 157 # If we're a tagged build, then we'll use stamping to update our 158 # build info, otherwise we'll use our side channel/best-effort 159 # approach to update it. 160 if ui.env_is_truthy("BUILDKITE_TAG"): 161 flags.append("--config=release-tagged") 162 else: 163 flags.append("--config=release-dev") 164 bazel_utils.write_git_hash() 165 elif self.profile == Profile.OPTIMIZED: 166 flags.append("--config=optimized") 167 168 if self.bazel_remote_cache: 169 flags.append(f"--remote_cache={self.bazel_remote_cache}") 170 171 if ui.env_is_truthy("CI"): 172 flags.append("--config=ci") 173 174 return flags
Returns a set of Bazel config flags to set for the build.
176 def rewrite_builder_path_for_host(self, path: Path) -> Path: 177 """Rewrite a path that is relative to the target directory inside the 178 builder to a path that is relative to the target directory on the host. 179 180 If path does is not relative to the target directory inside the builder, 181 it is returned unchanged. 182 """ 183 builder_target_dir = Path("/mnt/build") / xcompile.target(self.arch) 184 try: 185 return self.cargo_target_dir() / path.relative_to(builder_target_dir) 186 except ValueError: 187 return path
Rewrite a path that is relative to the target directory inside the builder to a path that is relative to the target directory on the host.
If path does is not relative to the target directory inside the builder, it is returned unchanged.
190def docker_images() -> set[str]: 191 """List the Docker images available on the local machine.""" 192 return set( 193 spawn.capture(["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"]) 194 .strip() 195 .split("\n") 196 )
List the Docker images available on the local machine.
199def is_docker_image_pushed(name: str) -> bool: 200 """Check whether the named image is pushed to Docker Hub. 201 202 Note that this operation requires a rather slow network request. 203 """ 204 proc = subprocess.run( 205 ["docker", "manifest", "inspect", name], 206 stdout=subprocess.DEVNULL, 207 stderr=subprocess.DEVNULL, 208 env=dict(os.environ, DOCKER_CLI_EXPERIMENTAL="enabled"), 209 ) 210 return proc.returncode == 0
Check whether the named image is pushed to Docker Hub.
Note that this operation requires a rather slow network request.
213def chmod_x(path: Path) -> None: 214 """Set the executable bit on a file or directory.""" 215 # https://stackoverflow.com/a/30463972/1122351 216 mode = os.stat(path).st_mode 217 mode |= (mode & 0o444) >> 2 # copy R bits to X 218 os.chmod(path, mode)
Set the executable bit on a file or directory.
221class PreImage: 222 """An action to run before building a Docker image. 223 224 Args: 225 rd: The `RepositoryDetails` for the repository. 226 path: The path to the `Image` associated with this action. 227 """ 228 229 def __init__(self, rd: RepositoryDetails, path: Path): 230 self.rd = rd 231 self.path = path 232 233 @classmethod 234 def prepare_batch(cls, instances: list["PreImage"]) -> Any: 235 """Prepare a batch of actions. 236 237 This is useful for `PreImage` actions that are more efficient when 238 their actions are applied to several images in bulk. 239 240 Returns an arbitrary output that is passed to `PreImage.run`. 241 """ 242 pass 243 244 def run(self, prep: Any) -> None: 245 """Perform the action. 246 247 Args: 248 prep: Any prep work returned by `prepare_batch`. 249 """ 250 pass 251 252 def inputs(self) -> set[str]: 253 """Return the files which are considered inputs to the action.""" 254 raise NotImplementedError 255 256 def extra(self) -> str: 257 """Returns additional data for incorporation in the fingerprint.""" 258 return ""
An action to run before building a Docker image.
Args:
rd: The RepositoryDetails
for the repository.
path: The path to the Image
associated with this action.
233 @classmethod 234 def prepare_batch(cls, instances: list["PreImage"]) -> Any: 235 """Prepare a batch of actions. 236 237 This is useful for `PreImage` actions that are more efficient when 238 their actions are applied to several images in bulk. 239 240 Returns an arbitrary output that is passed to `PreImage.run`. 241 """ 242 pass
Prepare a batch of actions.
This is useful for PreImage
actions that are more efficient when
their actions are applied to several images in bulk.
Returns an arbitrary output that is passed to PreImage.run
.
244 def run(self, prep: Any) -> None: 245 """Perform the action. 246 247 Args: 248 prep: Any prep work returned by `prepare_batch`. 249 """ 250 pass
Perform the action.
Args:
prep: Any prep work returned by prepare_batch
.
261class Copy(PreImage): 262 """A `PreImage` action which copies files from a directory. 263 264 See doc/developer/mzbuild.md for an explanation of the user-facing 265 parameters. 266 """ 267 268 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 269 super().__init__(rd, path) 270 271 self.source = config.pop("source", None) 272 if self.source is None: 273 raise ValueError("mzbuild config is missing 'source' argument") 274 275 self.destination = config.pop("destination", None) 276 if self.destination is None: 277 raise ValueError("mzbuild config is missing 'destination' argument") 278 279 self.matching = config.pop("matching", "*") 280 281 def run(self, prep: Any) -> None: 282 super().run(prep) 283 for src in self.inputs(): 284 dst = self.path / self.destination / src 285 dst.parent.mkdir(parents=True, exist_ok=True) 286 shutil.copy(self.rd.root / self.source / src, dst) 287 288 def inputs(self) -> set[str]: 289 return set(git.expand_globs(self.rd.root / self.source, self.matching))
A PreImage
action which copies files from a directory.
See doc/developer/mzbuild.md for an explanation of the user-facing parameters.
268 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 269 super().__init__(rd, path) 270 271 self.source = config.pop("source", None) 272 if self.source is None: 273 raise ValueError("mzbuild config is missing 'source' argument") 274 275 self.destination = config.pop("destination", None) 276 if self.destination is None: 277 raise ValueError("mzbuild config is missing 'destination' argument") 278 279 self.matching = config.pop("matching", "*")
281 def run(self, prep: Any) -> None: 282 super().run(prep) 283 for src in self.inputs(): 284 dst = self.path / self.destination / src 285 dst.parent.mkdir(parents=True, exist_ok=True) 286 shutil.copy(self.rd.root / self.source / src, dst)
Perform the action.
Args:
prep: Any prep work returned by prepare_batch
.
288 def inputs(self) -> set[str]: 289 return set(git.expand_globs(self.rd.root / self.source, self.matching))
Return the files which are considered inputs to the action.
Inherited Members
292class CargoPreImage(PreImage): 293 """A `PreImage` action that uses Cargo.""" 294 295 def inputs(self) -> set[str]: 296 inputs = { 297 "ci/builder", 298 "Cargo.toml", 299 # TODO(benesch): we could in theory fingerprint only the subset of 300 # Cargo.lock that applies to the crates at hand, but that is a 301 # *lot* of work. 302 "Cargo.lock", 303 ".cargo/config", 304 # Even though we are not always building with Bazel, consider its 305 # inputs so that developers with CI_BAZEL_BUILD=0 can still 306 # download the images from Dockerhub 307 ".bazelrc", 308 "WORKSPACE", 309 } 310 311 # Bazel has some rules and additive files that aren't directly 312 # associated with a crate, but can change how it's built. 313 additive_path = self.rd.root / "misc" / "bazel" 314 additive_files = ["*.bazel", "*.bzl"] 315 inputs |= { 316 f"misc/bazel/{path}" 317 for path in git.expand_globs(additive_path, *additive_files) 318 } 319 320 return inputs 321 322 def extra(self) -> str: 323 # Cargo images depend on the release mode and whether 324 # coverage/sanitizer is enabled. 325 flags: list[str] = [] 326 if self.rd.profile == Profile.RELEASE: 327 flags += "release" 328 if self.rd.profile == Profile.OPTIMIZED: 329 flags += "optimized" 330 if self.rd.coverage: 331 flags += "coverage" 332 if self.rd.sanitizer != Sanitizer.none: 333 flags += self.rd.sanitizer.value 334 flags.sort() 335 return ",".join(flags)
A PreImage
action that uses Cargo.
295 def inputs(self) -> set[str]: 296 inputs = { 297 "ci/builder", 298 "Cargo.toml", 299 # TODO(benesch): we could in theory fingerprint only the subset of 300 # Cargo.lock that applies to the crates at hand, but that is a 301 # *lot* of work. 302 "Cargo.lock", 303 ".cargo/config", 304 # Even though we are not always building with Bazel, consider its 305 # inputs so that developers with CI_BAZEL_BUILD=0 can still 306 # download the images from Dockerhub 307 ".bazelrc", 308 "WORKSPACE", 309 } 310 311 # Bazel has some rules and additive files that aren't directly 312 # associated with a crate, but can change how it's built. 313 additive_path = self.rd.root / "misc" / "bazel" 314 additive_files = ["*.bazel", "*.bzl"] 315 inputs |= { 316 f"misc/bazel/{path}" 317 for path in git.expand_globs(additive_path, *additive_files) 318 } 319 320 return inputs
Return the files which are considered inputs to the action.
322 def extra(self) -> str: 323 # Cargo images depend on the release mode and whether 324 # coverage/sanitizer is enabled. 325 flags: list[str] = [] 326 if self.rd.profile == Profile.RELEASE: 327 flags += "release" 328 if self.rd.profile == Profile.OPTIMIZED: 329 flags += "optimized" 330 if self.rd.coverage: 331 flags += "coverage" 332 if self.rd.sanitizer != Sanitizer.none: 333 flags += self.rd.sanitizer.value 334 flags.sort() 335 return ",".join(flags)
Returns additional data for incorporation in the fingerprint.
338class CargoBuild(CargoPreImage): 339 """A `PreImage` action that builds a single binary with Cargo. 340 341 See doc/developer/mzbuild.md for an explanation of the user-facing 342 parameters. 343 """ 344 345 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 346 super().__init__(rd, path) 347 bin = config.pop("bin", []) 348 self.bins = bin if isinstance(bin, list) else [bin] 349 example = config.pop("example", []) 350 self.examples = example if isinstance(example, list) else [example] 351 self.strip = config.pop("strip", True) 352 self.extract = config.pop("extract", {}) 353 354 bazel_bins = config.pop("bazel-bin") 355 self.bazel_bins = ( 356 bazel_bins if isinstance(bazel_bins, dict) else {self.bins[0]: bazel_bins} 357 ) 358 self.bazel_tars = config.pop("bazel-tar", {}) 359 360 if len(self.bins) == 0 and len(self.examples) == 0: 361 raise ValueError("mzbuild config is missing pre-build target") 362 for bin in self.bins: 363 if bin not in self.bazel_bins: 364 raise ValueError( 365 f"need to specify a 'bazel-bin' for '{bin}' at '{path}'" 366 ) 367 368 @staticmethod 369 def generate_bazel_build_command( 370 rd: RepositoryDetails, 371 bins: list[str], 372 examples: list[str], 373 bazel_bins: dict[str, str], 374 bazel_tars: dict[str, str], 375 ) -> list[str]: 376 assert ( 377 rd.bazel 378 ), "Programming error, tried to invoke Bazel when it is not enabled." 379 assert not rd.coverage, "Bazel doesn't support building with coverage." 380 381 rustflags = [] 382 if rd.sanitizer == Sanitizer.none: 383 rustflags += ["--cfg=tokio_unstable"] 384 385 extra_env = { 386 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 387 } 388 389 bazel_build = rd.build( 390 "build", 391 channel=None, 392 rustflags=rustflags, 393 extra_env=extra_env, 394 ) 395 396 for bin in bins: 397 bazel_build.append(bazel_bins[bin]) 398 for tar in bazel_tars: 399 bazel_build.append(tar) 400 # TODO(parkmycar): Make sure cargo-gazelle generates rust_binary targets for examples. 401 assert len(examples) == 0, "Bazel doesn't support building examples." 402 403 # Add extra Bazel config flags. 404 bazel_build.extend(rd.bazel_config()) 405 # Add flags for the Sanitizer 406 bazel_build.extend(rd.sanitizer.bazel_flags()) 407 408 return bazel_build 409 410 @staticmethod 411 def generate_cargo_build_command( 412 rd: RepositoryDetails, 413 bins: list[str], 414 examples: list[str], 415 ) -> list[str]: 416 assert ( 417 not rd.bazel 418 ), "Programming error, tried to invoke Cargo when Bazel is enabled." 419 420 rustflags = ( 421 rustc_flags.coverage 422 if rd.coverage 423 else ( 424 rustc_flags.sanitizer[rd.sanitizer] 425 if rd.sanitizer != Sanitizer.none 426 else ["--cfg=tokio_unstable"] 427 ) 428 ) 429 cflags = ( 430 [ 431 f"--target={target(rd.arch)}", 432 f"--gcc-toolchain=/opt/x-tools/{target(rd.arch)}/", 433 "-fuse-ld=lld", 434 f"--sysroot=/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/sysroot", 435 f"-L/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/lib64", 436 ] 437 + rustc_flags.sanitizer_cflags[rd.sanitizer] 438 if rd.sanitizer != Sanitizer.none 439 else [] 440 ) 441 extra_env = ( 442 { 443 "CFLAGS": " ".join(cflags), 444 "CXXFLAGS": " ".join(cflags), 445 "LDFLAGS": " ".join(cflags), 446 "CXXSTDLIB": "stdc++", 447 "CC": "cc", 448 "CXX": "c++", 449 "CPP": "clang-cpp-15", 450 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "cc", 451 "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "cc", 452 "PATH": f"/sanshim:/opt/x-tools/{target(rd.arch)}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 453 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 454 } 455 if rd.sanitizer != Sanitizer.none 456 else {} 457 ) 458 459 cargo_build = rd.build( 460 "build", channel=None, rustflags=rustflags, extra_env=extra_env 461 ) 462 463 packages = set() 464 for bin in bins: 465 cargo_build.extend(["--bin", bin]) 466 packages.add(rd.cargo_workspace.crate_for_bin(bin).name) 467 for example in examples: 468 cargo_build.extend(["--example", example]) 469 packages.add(rd.cargo_workspace.crate_for_example(example).name) 470 cargo_build.extend(f"--package={p}" for p in packages) 471 472 if rd.profile == Profile.RELEASE: 473 cargo_build.append("--release") 474 if rd.profile == Profile.OPTIMIZED: 475 cargo_build.extend(["--profile", "optimized"]) 476 if rd.sanitizer != Sanitizer.none: 477 # ASan doesn't work with jemalloc 478 cargo_build.append("--no-default-features") 479 # Uses more memory, so reduce the number of jobs 480 cargo_build.extend( 481 ["--jobs", str(round(multiprocessing.cpu_count() * 2 / 3))] 482 ) 483 484 return cargo_build 485 486 @classmethod 487 def prepare_batch(cls, cargo_builds: list["PreImage"]) -> dict[str, Any]: 488 super().prepare_batch(cargo_builds) 489 490 if not cargo_builds: 491 return {} 492 493 # Building all binaries and examples in the same `cargo build` command 494 # allows Cargo to link in parallel with other work, which can 495 # meaningfully speed up builds. 496 497 rd: RepositoryDetails | None = None 498 builds = cast(list[CargoBuild], cargo_builds) 499 bins = set() 500 examples = set() 501 bazel_bins = dict() 502 bazel_tars = dict() 503 for build in builds: 504 if not rd: 505 rd = build.rd 506 bins.update(build.bins) 507 examples.update(build.examples) 508 bazel_bins.update(build.bazel_bins) 509 bazel_tars.update(build.bazel_tars) 510 assert rd 511 512 ui.section(f"Common build for: {', '.join(bins | examples)}") 513 514 if rd.bazel: 515 cargo_build = cls.generate_bazel_build_command( 516 rd, list(bins), list(examples), bazel_bins, bazel_tars 517 ) 518 else: 519 cargo_build = cls.generate_cargo_build_command( 520 rd, list(bins), list(examples) 521 ) 522 523 spawn.runv(cargo_build, cwd=rd.root) 524 525 # Re-run with JSON-formatted messages and capture the output so we can 526 # later analyze the build artifacts in `run`. This should be nearly 527 # instantaneous since we just compiled above with the same crates and 528 # features. (We don't want to do the compile above with JSON-formatted 529 # messages because it wouldn't be human readable.) 530 if rd.bazel: 531 # TODO(parkmycar): Having to assign the same compilation flags as the build process 532 # is a bit brittle. It would be better if the Bazel build process itself could 533 # output the file to a known location. 534 options = rd.bazel_config() 535 paths_to_binaries = {} 536 for bin in bins: 537 paths = bazel_utils.output_paths(bazel_bins[bin], options) 538 assert len(paths) == 1, f"{bazel_bins[bin]} output more than 1 file" 539 paths_to_binaries[bin] = paths[0] 540 for tar in bazel_tars: 541 paths = bazel_utils.output_paths(tar, options) 542 assert len(paths) == 1, f"more than one output path found for '{tar}'" 543 paths_to_binaries[tar] = paths[0] 544 prep = {"bazel": paths_to_binaries} 545 else: 546 json_output = spawn.capture( 547 cargo_build + ["--message-format=json"], 548 cwd=rd.root, 549 ) 550 prep = {"cargo": json_output} 551 552 return prep 553 554 def build(self, build_output: dict[str, Any]) -> None: 555 cargo_profile = ( 556 "release" 557 if self.rd.profile == Profile.RELEASE 558 else "optimized" if self.rd.profile == Profile.OPTIMIZED else "debug" 559 ) 560 561 def copy(src: Path, relative_dst: Path) -> None: 562 exe_path = self.path / relative_dst 563 exe_path.parent.mkdir(parents=True, exist_ok=True) 564 shutil.copy(src, exe_path) 565 566 # Bazel doesn't add write or exec permissions for built binaries 567 # but `strip` and `objcopy` need write permissions and we add exec 568 # permissions for the built Docker images. 569 current_perms = os.stat(exe_path).st_mode 570 new_perms = ( 571 current_perms 572 # chmod +wx 573 | stat.S_IWUSR 574 | stat.S_IWGRP 575 | stat.S_IWOTH 576 | stat.S_IXUSR 577 | stat.S_IXGRP 578 | stat.S_IXOTH 579 ) 580 os.chmod(exe_path, new_perms) 581 582 if self.strip: 583 # The debug information is large enough that it slows down CI, 584 # since we're packaging these binaries up into Docker images and 585 # shipping them around. 586 spawn.runv( 587 [*self.rd.tool("strip"), "--strip-debug", exe_path], 588 cwd=self.rd.root, 589 ) 590 else: 591 # Even if we've been asked not to strip the binary, remove the 592 # `.debug_pubnames` and `.debug_pubtypes` sections. These are just 593 # indexes that speed up launching a debugger against the binary, 594 # and we're happy to have slower debugger start up in exchange for 595 # smaller binaries. Plus the sections have been obsoleted by a 596 # `.debug_names` section in DWARF 5, and so debugger support for 597 # `.debug_pubnames`/`.debug_pubtypes` is minimal anyway. 598 # See: https://github.com/rust-lang/rust/issues/46034 599 spawn.runv( 600 [ 601 *self.rd.tool("objcopy"), 602 "-R", 603 ".debug_pubnames", 604 "-R", 605 ".debug_pubtypes", 606 exe_path, 607 ], 608 cwd=self.rd.root, 609 ) 610 611 for bin in self.bins: 612 if "bazel" in build_output: 613 src_path = self.rd.bazel_workspace_dir() / build_output["bazel"][bin] 614 else: 615 src_path = self.rd.cargo_target_dir() / cargo_profile / bin 616 copy(src_path, bin) 617 for example in self.examples: 618 src_path = ( 619 self.rd.cargo_target_dir() / cargo_profile / Path("examples") / example 620 ) 621 copy(src_path, Path("examples") / example) 622 623 # Bazel doesn't support 'extract', instead you need to use 'bazel-tar' 624 if self.extract and "bazel" not in build_output: 625 cargo_build_json_output = build_output["cargo"] 626 627 target_dir = self.rd.cargo_target_dir() 628 for line in cargo_build_json_output.split("\n"): 629 if line.strip() == "" or not line.startswith("{"): 630 continue 631 message = json.loads(line) 632 if message["reason"] != "build-script-executed": 633 continue 634 out_dir = self.rd.rewrite_builder_path_for_host( 635 Path(message["out_dir"]) 636 ) 637 if not out_dir.is_relative_to(target_dir): 638 # Some crates are built for both the host and the target. 639 # Ignore the built-for-host out dir. 640 continue 641 # parse the package name from a package_id that looks like one of: 642 # git+https://github.com/MaterializeInc/rust-server-sdk#launchdarkly-server-sdk@1.0.0 643 # path+file:///Users/roshan/materialize/src/catalog#mz-catalog@0.0.0 644 # registry+https://github.com/rust-lang/crates.io-index#num-rational@0.4.0 645 # file:///path/to/my-package#0.1.0 646 package_id = message["package_id"] 647 if "@" in package_id: 648 package = package_id.split("@")[0].split("#")[-1] 649 else: 650 package = message["package_id"].split("#")[0].split("/")[-1] 651 for src, dst in self.extract.get(package, {}).items(): 652 spawn.runv(["cp", "-R", out_dir / src, self.path / dst]) 653 654 if self.bazel_tars and "bazel" in build_output: 655 ui.section("Extracing 'bazel-tar'") 656 for tar in self.bazel_tars: 657 # Where Bazel built the tarball. 658 tar_path = self.rd.bazel_workspace_dir() / build_output["bazel"][tar] 659 # Where we need to extract it into. 660 tar_dest = self.path / self.bazel_tars[tar] 661 ui.say(f"extracing {tar_path} to {tar_dest}") 662 663 with tarfile.open(tar_path, "r") as tar_file: 664 os.makedirs(tar_dest, exist_ok=True) 665 tar_file.extractall(path=tar_dest) 666 667 self.acquired = True 668 669 def run(self, prep: dict[str, Any]) -> None: 670 super().run(prep) 671 self.build(prep) 672 673 def inputs(self) -> set[str]: 674 deps = set() 675 676 for bin in self.bins: 677 crate = self.rd.cargo_workspace.crate_for_bin(bin) 678 deps |= self.rd.cargo_workspace.transitive_path_dependencies(crate) 679 for example in self.examples: 680 crate = self.rd.cargo_workspace.crate_for_example(example) 681 deps |= self.rd.cargo_workspace.transitive_path_dependencies( 682 crate, dev=True 683 ) 684 inputs = super().inputs() | set(inp for dep in deps for inp in dep.inputs()) 685 # Even though we are not always building with Bazel, consider its 686 # inputs so that developers with CI_BAZEL_BUILD=0 can still 687 # download the images from Dockerhub 688 inputs |= {"BUILD.bazel"} 689 690 return inputs
A PreImage
action that builds a single binary with Cargo.
See doc/developer/mzbuild.md for an explanation of the user-facing parameters.
345 def __init__(self, rd: RepositoryDetails, path: Path, config: dict[str, Any]): 346 super().__init__(rd, path) 347 bin = config.pop("bin", []) 348 self.bins = bin if isinstance(bin, list) else [bin] 349 example = config.pop("example", []) 350 self.examples = example if isinstance(example, list) else [example] 351 self.strip = config.pop("strip", True) 352 self.extract = config.pop("extract", {}) 353 354 bazel_bins = config.pop("bazel-bin") 355 self.bazel_bins = ( 356 bazel_bins if isinstance(bazel_bins, dict) else {self.bins[0]: bazel_bins} 357 ) 358 self.bazel_tars = config.pop("bazel-tar", {}) 359 360 if len(self.bins) == 0 and len(self.examples) == 0: 361 raise ValueError("mzbuild config is missing pre-build target") 362 for bin in self.bins: 363 if bin not in self.bazel_bins: 364 raise ValueError( 365 f"need to specify a 'bazel-bin' for '{bin}' at '{path}'" 366 )
368 @staticmethod 369 def generate_bazel_build_command( 370 rd: RepositoryDetails, 371 bins: list[str], 372 examples: list[str], 373 bazel_bins: dict[str, str], 374 bazel_tars: dict[str, str], 375 ) -> list[str]: 376 assert ( 377 rd.bazel 378 ), "Programming error, tried to invoke Bazel when it is not enabled." 379 assert not rd.coverage, "Bazel doesn't support building with coverage." 380 381 rustflags = [] 382 if rd.sanitizer == Sanitizer.none: 383 rustflags += ["--cfg=tokio_unstable"] 384 385 extra_env = { 386 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 387 } 388 389 bazel_build = rd.build( 390 "build", 391 channel=None, 392 rustflags=rustflags, 393 extra_env=extra_env, 394 ) 395 396 for bin in bins: 397 bazel_build.append(bazel_bins[bin]) 398 for tar in bazel_tars: 399 bazel_build.append(tar) 400 # TODO(parkmycar): Make sure cargo-gazelle generates rust_binary targets for examples. 401 assert len(examples) == 0, "Bazel doesn't support building examples." 402 403 # Add extra Bazel config flags. 404 bazel_build.extend(rd.bazel_config()) 405 # Add flags for the Sanitizer 406 bazel_build.extend(rd.sanitizer.bazel_flags()) 407 408 return bazel_build
410 @staticmethod 411 def generate_cargo_build_command( 412 rd: RepositoryDetails, 413 bins: list[str], 414 examples: list[str], 415 ) -> list[str]: 416 assert ( 417 not rd.bazel 418 ), "Programming error, tried to invoke Cargo when Bazel is enabled." 419 420 rustflags = ( 421 rustc_flags.coverage 422 if rd.coverage 423 else ( 424 rustc_flags.sanitizer[rd.sanitizer] 425 if rd.sanitizer != Sanitizer.none 426 else ["--cfg=tokio_unstable"] 427 ) 428 ) 429 cflags = ( 430 [ 431 f"--target={target(rd.arch)}", 432 f"--gcc-toolchain=/opt/x-tools/{target(rd.arch)}/", 433 "-fuse-ld=lld", 434 f"--sysroot=/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/sysroot", 435 f"-L/opt/x-tools/{target(rd.arch)}/{target(rd.arch)}/lib64", 436 ] 437 + rustc_flags.sanitizer_cflags[rd.sanitizer] 438 if rd.sanitizer != Sanitizer.none 439 else [] 440 ) 441 extra_env = ( 442 { 443 "CFLAGS": " ".join(cflags), 444 "CXXFLAGS": " ".join(cflags), 445 "LDFLAGS": " ".join(cflags), 446 "CXXSTDLIB": "stdc++", 447 "CC": "cc", 448 "CXX": "c++", 449 "CPP": "clang-cpp-15", 450 "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "cc", 451 "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "cc", 452 "PATH": f"/sanshim:/opt/x-tools/{target(rd.arch)}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 453 "TSAN_OPTIONS": "report_bugs=0", # build-scripts fail 454 } 455 if rd.sanitizer != Sanitizer.none 456 else {} 457 ) 458 459 cargo_build = rd.build( 460 "build", channel=None, rustflags=rustflags, extra_env=extra_env 461 ) 462 463 packages = set() 464 for bin in bins: 465 cargo_build.extend(["--bin", bin]) 466 packages.add(rd.cargo_workspace.crate_for_bin(bin).name) 467 for example in examples: 468 cargo_build.extend(["--example", example]) 469 packages.add(rd.cargo_workspace.crate_for_example(example).name) 470 cargo_build.extend(f"--package={p}" for p in packages) 471 472 if rd.profile == Profile.RELEASE: 473 cargo_build.append("--release") 474 if rd.profile == Profile.OPTIMIZED: 475 cargo_build.extend(["--profile", "optimized"]) 476 if rd.sanitizer != Sanitizer.none: 477 # ASan doesn't work with jemalloc 478 cargo_build.append("--no-default-features") 479 # Uses more memory, so reduce the number of jobs 480 cargo_build.extend( 481 ["--jobs", str(round(multiprocessing.cpu_count() * 2 / 3))] 482 ) 483 484 return cargo_build
486 @classmethod 487 def prepare_batch(cls, cargo_builds: list["PreImage"]) -> dict[str, Any]: 488 super().prepare_batch(cargo_builds) 489 490 if not cargo_builds: 491 return {} 492 493 # Building all binaries and examples in the same `cargo build` command 494 # allows Cargo to link in parallel with other work, which can 495 # meaningfully speed up builds. 496 497 rd: RepositoryDetails | None = None 498 builds = cast(list[CargoBuild], cargo_builds) 499 bins = set() 500 examples = set() 501 bazel_bins = dict() 502 bazel_tars = dict() 503 for build in builds: 504 if not rd: 505 rd = build.rd 506 bins.update(build.bins) 507 examples.update(build.examples) 508 bazel_bins.update(build.bazel_bins) 509 bazel_tars.update(build.bazel_tars) 510 assert rd 511 512 ui.section(f"Common build for: {', '.join(bins | examples)}") 513 514 if rd.bazel: 515 cargo_build = cls.generate_bazel_build_command( 516 rd, list(bins), list(examples), bazel_bins, bazel_tars 517 ) 518 else: 519 cargo_build = cls.generate_cargo_build_command( 520 rd, list(bins), list(examples) 521 ) 522 523 spawn.runv(cargo_build, cwd=rd.root) 524 525 # Re-run with JSON-formatted messages and capture the output so we can 526 # later analyze the build artifacts in `run`. This should be nearly 527 # instantaneous since we just compiled above with the same crates and 528 # features. (We don't want to do the compile above with JSON-formatted 529 # messages because it wouldn't be human readable.) 530 if rd.bazel: 531 # TODO(parkmycar): Having to assign the same compilation flags as the build process 532 # is a bit brittle. It would be better if the Bazel build process itself could 533 # output the file to a known location. 534 options = rd.bazel_config() 535 paths_to_binaries = {} 536 for bin in bins: 537 paths = bazel_utils.output_paths(bazel_bins[bin], options) 538 assert len(paths) == 1, f"{bazel_bins[bin]} output more than 1 file" 539 paths_to_binaries[bin] = paths[0] 540 for tar in bazel_tars: 541 paths = bazel_utils.output_paths(tar, options) 542 assert len(paths) == 1, f"more than one output path found for '{tar}'" 543 paths_to_binaries[tar] = paths[0] 544 prep = {"bazel": paths_to_binaries} 545 else: 546 json_output = spawn.capture( 547 cargo_build + ["--message-format=json"], 548 cwd=rd.root, 549 ) 550 prep = {"cargo": json_output} 551 552 return prep
Prepare a batch of actions.
This is useful for PreImage
actions that are more efficient when
their actions are applied to several images in bulk.
Returns an arbitrary output that is passed to PreImage.run
.
554 def build(self, build_output: dict[str, Any]) -> None: 555 cargo_profile = ( 556 "release" 557 if self.rd.profile == Profile.RELEASE 558 else "optimized" if self.rd.profile == Profile.OPTIMIZED else "debug" 559 ) 560 561 def copy(src: Path, relative_dst: Path) -> None: 562 exe_path = self.path / relative_dst 563 exe_path.parent.mkdir(parents=True, exist_ok=True) 564 shutil.copy(src, exe_path) 565 566 # Bazel doesn't add write or exec permissions for built binaries 567 # but `strip` and `objcopy` need write permissions and we add exec 568 # permissions for the built Docker images. 569 current_perms = os.stat(exe_path).st_mode 570 new_perms = ( 571 current_perms 572 # chmod +wx 573 | stat.S_IWUSR 574 | stat.S_IWGRP 575 | stat.S_IWOTH 576 | stat.S_IXUSR 577 | stat.S_IXGRP 578 | stat.S_IXOTH 579 ) 580 os.chmod(exe_path, new_perms) 581 582 if self.strip: 583 # The debug information is large enough that it slows down CI, 584 # since we're packaging these binaries up into Docker images and 585 # shipping them around. 586 spawn.runv( 587 [*self.rd.tool("strip"), "--strip-debug", exe_path], 588 cwd=self.rd.root, 589 ) 590 else: 591 # Even if we've been asked not to strip the binary, remove the 592 # `.debug_pubnames` and `.debug_pubtypes` sections. These are just 593 # indexes that speed up launching a debugger against the binary, 594 # and we're happy to have slower debugger start up in exchange for 595 # smaller binaries. Plus the sections have been obsoleted by a 596 # `.debug_names` section in DWARF 5, and so debugger support for 597 # `.debug_pubnames`/`.debug_pubtypes` is minimal anyway. 598 # See: https://github.com/rust-lang/rust/issues/46034 599 spawn.runv( 600 [ 601 *self.rd.tool("objcopy"), 602 "-R", 603 ".debug_pubnames", 604 "-R", 605 ".debug_pubtypes", 606 exe_path, 607 ], 608 cwd=self.rd.root, 609 ) 610 611 for bin in self.bins: 612 if "bazel" in build_output: 613 src_path = self.rd.bazel_workspace_dir() / build_output["bazel"][bin] 614 else: 615 src_path = self.rd.cargo_target_dir() / cargo_profile / bin 616 copy(src_path, bin) 617 for example in self.examples: 618 src_path = ( 619 self.rd.cargo_target_dir() / cargo_profile / Path("examples") / example 620 ) 621 copy(src_path, Path("examples") / example) 622 623 # Bazel doesn't support 'extract', instead you need to use 'bazel-tar' 624 if self.extract and "bazel" not in build_output: 625 cargo_build_json_output = build_output["cargo"] 626 627 target_dir = self.rd.cargo_target_dir() 628 for line in cargo_build_json_output.split("\n"): 629 if line.strip() == "" or not line.startswith("{"): 630 continue 631 message = json.loads(line) 632 if message["reason"] != "build-script-executed": 633 continue 634 out_dir = self.rd.rewrite_builder_path_for_host( 635 Path(message["out_dir"]) 636 ) 637 if not out_dir.is_relative_to(target_dir): 638 # Some crates are built for both the host and the target. 639 # Ignore the built-for-host out dir. 640 continue 641 # parse the package name from a package_id that looks like one of: 642 # git+https://github.com/MaterializeInc/rust-server-sdk#launchdarkly-server-sdk@1.0.0 643 # path+file:///Users/roshan/materialize/src/catalog#mz-catalog@0.0.0 644 # registry+https://github.com/rust-lang/crates.io-index#num-rational@0.4.0 645 # file:///path/to/my-package#0.1.0 646 package_id = message["package_id"] 647 if "@" in package_id: 648 package = package_id.split("@")[0].split("#")[-1] 649 else: 650 package = message["package_id"].split("#")[0].split("/")[-1] 651 for src, dst in self.extract.get(package, {}).items(): 652 spawn.runv(["cp", "-R", out_dir / src, self.path / dst]) 653 654 if self.bazel_tars and "bazel" in build_output: 655 ui.section("Extracing 'bazel-tar'") 656 for tar in self.bazel_tars: 657 # Where Bazel built the tarball. 658 tar_path = self.rd.bazel_workspace_dir() / build_output["bazel"][tar] 659 # Where we need to extract it into. 660 tar_dest = self.path / self.bazel_tars[tar] 661 ui.say(f"extracing {tar_path} to {tar_dest}") 662 663 with tarfile.open(tar_path, "r") as tar_file: 664 os.makedirs(tar_dest, exist_ok=True) 665 tar_file.extractall(path=tar_dest) 666 667 self.acquired = True
Perform the action.
Args:
prep: Any prep work returned by prepare_batch
.
673 def inputs(self) -> set[str]: 674 deps = set() 675 676 for bin in self.bins: 677 crate = self.rd.cargo_workspace.crate_for_bin(bin) 678 deps |= self.rd.cargo_workspace.transitive_path_dependencies(crate) 679 for example in self.examples: 680 crate = self.rd.cargo_workspace.crate_for_example(example) 681 deps |= self.rd.cargo_workspace.transitive_path_dependencies( 682 crate, dev=True 683 ) 684 inputs = super().inputs() | set(inp for dep in deps for inp in dep.inputs()) 685 # Even though we are not always building with Bazel, consider its 686 # inputs so that developers with CI_BAZEL_BUILD=0 can still 687 # download the images from Dockerhub 688 inputs |= {"BUILD.bazel"} 689 690 return inputs
Return the files which are considered inputs to the action.
Inherited Members
693class Image: 694 """A Docker image whose build and dependencies are managed by mzbuild. 695 696 An image corresponds to a directory in a repository that contains a 697 `mzbuild.yml` file. This directory is called an "mzbuild context." 698 699 Attributes: 700 name: The name of the image. 701 publish: Whether the image should be pushed to Docker Hub. 702 depends_on: The names of the images upon which this image depends. 703 root: The path to the root of the associated `Repository`. 704 path: The path to the directory containing the `mzbuild.yml` 705 configuration file. 706 pre_images: Optional actions to perform before running `docker build`. 707 build_args: An optional list of --build-arg to pass to the dockerfile 708 """ 709 710 _DOCKERFILE_MZFROM_RE = re.compile(rb"^MZFROM\s*(\S+)") 711 712 def __init__(self, rd: RepositoryDetails, path: Path): 713 self.rd = rd 714 self.path = path 715 self.pre_images: list[PreImage] = [] 716 with open(self.path / "mzbuild.yml") as f: 717 data = yaml.safe_load(f) 718 self.name: str = data.pop("name") 719 self.publish: bool = data.pop("publish", True) 720 self.description: str | None = data.pop("description", None) 721 self.mainline: bool = data.pop("mainline", True) 722 for pre_image in data.pop("pre-image", []): 723 typ = pre_image.pop("type", None) 724 if typ == "cargo-build": 725 self.pre_images.append(CargoBuild(self.rd, self.path, pre_image)) 726 elif typ == "copy": 727 self.pre_images.append(Copy(self.rd, self.path, pre_image)) 728 else: 729 raise ValueError( 730 f"mzbuild config in {self.path} has unknown pre-image type" 731 ) 732 self.build_args = data.pop("build-args", {}) 733 734 if re.search(r"[^A-Za-z0-9\-]", self.name): 735 raise ValueError( 736 f"mzbuild image name {self.name} contains invalid character; only alphanumerics and hyphens allowed" 737 ) 738 739 self.depends_on: list[str] = [] 740 with open(self.path / "Dockerfile", "rb") as f: 741 for line in f: 742 match = self._DOCKERFILE_MZFROM_RE.match(line) 743 if match: 744 self.depends_on.append(match.group(1).decode()) 745 746 def sync_description(self) -> None: 747 """Sync the description to Docker Hub if the image is publishable 748 and a README.md file exists.""" 749 750 if not self.publish: 751 ui.say(f"{self.name} is not publishable") 752 return 753 754 readme_path = self.path / "README.md" 755 has_readme = readme_path.exists() 756 if not has_readme: 757 ui.say(f"{self.name} has no README.md or description") 758 return 759 760 docker_config = os.getenv("DOCKER_CONFIG") 761 spawn.runv( 762 [ 763 "docker", 764 "pushrm", 765 f"--file={readme_path}", 766 *([f"--config={docker_config}/config.json"] if docker_config else []), 767 *([f"--short={self.description}"] if self.description else []), 768 self.docker_name(), 769 ] 770 ) 771 772 def docker_name(self, tag: str | None = None) -> str: 773 """Return the name of the image on Docker Hub at the given tag.""" 774 name = f"{self.rd.image_registry}/{self.rd.image_prefix}{self.name}" 775 if tag: 776 name += f":{tag}" 777 return name
A Docker image whose build and dependencies are managed by mzbuild.
An image corresponds to a directory in a repository that contains a
mzbuild.yml
file. This directory is called an "mzbuild context."
Attributes:
name: The name of the image.
publish: Whether the image should be pushed to Docker Hub.
depends_on: The names of the images upon which this image depends.
root: The path to the root of the associated Repository
.
path: The path to the directory containing the mzbuild.yml
configuration file.
pre_images: Optional actions to perform before running docker build
.
build_args: An optional list of --build-arg to pass to the dockerfile
712 def __init__(self, rd: RepositoryDetails, path: Path): 713 self.rd = rd 714 self.path = path 715 self.pre_images: list[PreImage] = [] 716 with open(self.path / "mzbuild.yml") as f: 717 data = yaml.safe_load(f) 718 self.name: str = data.pop("name") 719 self.publish: bool = data.pop("publish", True) 720 self.description: str | None = data.pop("description", None) 721 self.mainline: bool = data.pop("mainline", True) 722 for pre_image in data.pop("pre-image", []): 723 typ = pre_image.pop("type", None) 724 if typ == "cargo-build": 725 self.pre_images.append(CargoBuild(self.rd, self.path, pre_image)) 726 elif typ == "copy": 727 self.pre_images.append(Copy(self.rd, self.path, pre_image)) 728 else: 729 raise ValueError( 730 f"mzbuild config in {self.path} has unknown pre-image type" 731 ) 732 self.build_args = data.pop("build-args", {}) 733 734 if re.search(r"[^A-Za-z0-9\-]", self.name): 735 raise ValueError( 736 f"mzbuild image name {self.name} contains invalid character; only alphanumerics and hyphens allowed" 737 ) 738 739 self.depends_on: list[str] = [] 740 with open(self.path / "Dockerfile", "rb") as f: 741 for line in f: 742 match = self._DOCKERFILE_MZFROM_RE.match(line) 743 if match: 744 self.depends_on.append(match.group(1).decode())
746 def sync_description(self) -> None: 747 """Sync the description to Docker Hub if the image is publishable 748 and a README.md file exists.""" 749 750 if not self.publish: 751 ui.say(f"{self.name} is not publishable") 752 return 753 754 readme_path = self.path / "README.md" 755 has_readme = readme_path.exists() 756 if not has_readme: 757 ui.say(f"{self.name} has no README.md or description") 758 return 759 760 docker_config = os.getenv("DOCKER_CONFIG") 761 spawn.runv( 762 [ 763 "docker", 764 "pushrm", 765 f"--file={readme_path}", 766 *([f"--config={docker_config}/config.json"] if docker_config else []), 767 *([f"--short={self.description}"] if self.description else []), 768 self.docker_name(), 769 ] 770 )
Sync the description to Docker Hub if the image is publishable and a README.md file exists.
772 def docker_name(self, tag: str | None = None) -> str: 773 """Return the name of the image on Docker Hub at the given tag.""" 774 name = f"{self.rd.image_registry}/{self.rd.image_prefix}{self.name}" 775 if tag: 776 name += f":{tag}" 777 return name
Return the name of the image on Docker Hub at the given tag.
780class ResolvedImage: 781 """An `Image` whose dependencies have been resolved. 782 783 Attributes: 784 image: The underlying `Image`. 785 acquired: Whether the image is available locally. 786 dependencies: A mapping from dependency name to `ResolvedImage` for 787 each of the images that `image` depends upon. 788 """ 789 790 def __init__(self, image: Image, dependencies: Iterable["ResolvedImage"]): 791 self.image = image 792 self.acquired = False 793 self.dependencies = {} 794 for d in dependencies: 795 self.dependencies[d.name] = d 796 797 def __repr__(self) -> str: 798 return f"ResolvedImage<{self.spec()}>" 799 800 @property 801 def name(self) -> str: 802 """The name of the underlying image.""" 803 return self.image.name 804 805 @property 806 def publish(self) -> bool: 807 """Whether the underlying image should be pushed to Docker Hub.""" 808 return self.image.publish 809 810 def spec(self) -> str: 811 """Return the "spec" for the image. 812 813 A spec is the unique identifier for the image given its current 814 fingerprint. It is a valid Docker Hub name. 815 """ 816 return self.image.docker_name(tag=f"mzbuild-{self.fingerprint()}") 817 818 def write_dockerfile(self) -> IO[bytes]: 819 """Render the Dockerfile without mzbuild directives. 820 821 Returns: 822 file: A handle to a temporary file containing the adjusted 823 Dockerfile.""" 824 with open(self.image.path / "Dockerfile", "rb") as f: 825 lines = f.readlines() 826 f = TemporaryFile() 827 for line in lines: 828 match = Image._DOCKERFILE_MZFROM_RE.match(line) 829 if match: 830 image = match.group(1).decode() 831 spec = self.dependencies[image].spec() 832 line = Image._DOCKERFILE_MZFROM_RE.sub(b"FROM %b" % spec.encode(), line) 833 f.write(line) 834 f.seek(0) 835 return f 836 837 def build(self, prep: dict[type[PreImage], Any]) -> None: 838 """Build the image from source. 839 840 Requires that the caller has already acquired all dependencies and 841 prepared all `PreImage` actions via `PreImage.prepare_batch`. 842 """ 843 ui.section(f"Building {self.spec()}") 844 spawn.runv(["git", "clean", "-ffdX", self.image.path]) 845 846 for pre_image in self.image.pre_images: 847 pre_image.run(prep[type(pre_image)]) 848 build_args = { 849 **self.image.build_args, 850 "ARCH_GCC": str(self.image.rd.arch), 851 "ARCH_GO": self.image.rd.arch.go_str(), 852 "CI_SANITIZER": str(self.image.rd.sanitizer), 853 } 854 f = self.write_dockerfile() 855 cmd: Sequence[str] = [ 856 "docker", 857 "build", 858 "-f", 859 "-", 860 *(f"--build-arg={k}={v}" for k, v in build_args.items()), 861 "-t", 862 self.spec(), 863 f"--platform=linux/{self.image.rd.arch.go_str()}", 864 str(self.image.path), 865 ] 866 spawn.runv(cmd, stdin=f, stdout=sys.stderr.buffer) 867 868 def try_pull(self, max_retries: int) -> bool: 869 """Download the image if it does not exist locally. Returns whether it was found.""" 870 ui.header(f"Acquiring {self.spec()}") 871 command = ["docker", "pull"] 872 # --quiet skips printing the progress bar, which does not display well in CI. 873 if ui.env_is_truthy("CI"): 874 command.append("--quiet") 875 command.append(self.spec()) 876 if not self.acquired: 877 sleep_time = 1 878 for retry in range(1, max_retries + 1): 879 try: 880 spawn.runv( 881 command, 882 stdout=sys.stderr.buffer, 883 ) 884 self.acquired = True 885 except subprocess.CalledProcessError: 886 if retry < max_retries: 887 # There seems to be no good way to tell what error 888 # happened based on error code 889 # (https://github.com/docker/cli/issues/538) and we 890 # want to print output directly to terminal. 891 print(f"Retrying in {sleep_time}s ...") 892 time.sleep(sleep_time) 893 sleep_time *= 2 894 continue 895 else: 896 break 897 return self.acquired 898 899 def is_published_if_necessary(self) -> bool: 900 """Report whether the image exists on Docker Hub if it is publishable.""" 901 if self.publish and is_docker_image_pushed(self.spec()): 902 ui.say(f"{self.spec()} already exists") 903 return True 904 return False 905 906 def run( 907 self, 908 args: list[str] = [], 909 docker_args: list[str] = [], 910 env: dict[str, str] = {}, 911 ) -> None: 912 """Run a command in the image. 913 914 Creates a container from the image and runs the command described by 915 `args` in the image. 916 """ 917 envs = [] 918 for key, val in env.items(): 919 envs.extend(["--env", f"{key}={val}"]) 920 spawn.runv( 921 [ 922 "docker", 923 "run", 924 "--tty", 925 "--rm", 926 *envs, 927 "--init", 928 *docker_args, 929 self.spec(), 930 *args, 931 ], 932 ) 933 934 def list_dependencies(self, transitive: bool = False) -> set[str]: 935 out = set() 936 for dep in self.dependencies.values(): 937 out.add(dep.name) 938 if transitive: 939 out |= dep.list_dependencies(transitive) 940 return out 941 942 def inputs(self, transitive: bool = False) -> set[str]: 943 """List the files tracked as inputs to the image. 944 945 These files are used to compute the fingerprint for the image. See 946 `ResolvedImage.fingerprint` for details. 947 948 Returns: 949 inputs: A list of input files, relative to the root of the 950 repository. 951 """ 952 paths = set(git.expand_globs(self.image.rd.root, f"{self.image.path}/**")) 953 if not paths: 954 # While we could find an `mzbuild.yml` file for this service, expland_globs didn't 955 # return any files that matched this service. At the very least, the `mzbuild.yml` 956 # file itself should have been returned. We have a bug if paths is empty. 957 raise AssertionError( 958 f"{self.image.name} mzbuild exists but its files are unknown to git" 959 ) 960 for pre_image in self.image.pre_images: 961 paths |= pre_image.inputs() 962 if transitive: 963 for dep in self.dependencies.values(): 964 paths |= dep.inputs(transitive) 965 return paths 966 967 @cache 968 def fingerprint(self) -> Fingerprint: 969 """Fingerprint the inputs to the image. 970 971 Compute the fingerprint of the image. Changing the contents of any of 972 the files or adding or removing files to the image will change the 973 fingerprint, as will modifying the inputs to any of its dependencies. 974 975 The image considers all non-gitignored files in its mzbuild context to 976 be inputs. If it has a pre-image action, that action may add additional 977 inputs via `PreImage.inputs`. 978 """ 979 self_hash = hashlib.sha1() 980 for rel_path in sorted( 981 set(git.expand_globs(self.image.rd.root, *self.inputs())) 982 ): 983 abs_path = self.image.rd.root / rel_path 984 file_hash = hashlib.sha1() 985 raw_file_mode = os.lstat(abs_path).st_mode 986 # Compute a simplified file mode using the same rules as Git. 987 # https://github.com/git/git/blob/3bab5d562/Documentation/git-fast-import.txt#L610-L616 988 if stat.S_ISLNK(raw_file_mode): 989 file_mode = 0o120000 990 elif raw_file_mode & stat.S_IXUSR: 991 file_mode = 0o100755 992 else: 993 file_mode = 0o100644 994 with open(abs_path, "rb") as f: 995 file_hash.update(f.read()) 996 self_hash.update(file_mode.to_bytes(2, byteorder="big")) 997 self_hash.update(rel_path.encode()) 998 self_hash.update(file_hash.digest()) 999 self_hash.update(b"\0") 1000 1001 for pre_image in self.image.pre_images: 1002 self_hash.update(pre_image.extra().encode()) 1003 self_hash.update(b"\0") 1004 1005 self_hash.update(f"arch={self.image.rd.arch}".encode()) 1006 self_hash.update(f"coverage={self.image.rd.coverage}".encode()) 1007 self_hash.update(f"sanitizer={self.image.rd.sanitizer}".encode()) 1008 1009 full_hash = hashlib.sha1() 1010 full_hash.update(self_hash.digest()) 1011 for dep in sorted(self.dependencies.values(), key=lambda d: d.name): 1012 full_hash.update(dep.name.encode()) 1013 full_hash.update(dep.fingerprint()) 1014 full_hash.update(b"\0") 1015 1016 return Fingerprint(full_hash.digest())
An Image
whose dependencies have been resolved.
Attributes:
image: The underlying Image
.
acquired: Whether the image is available locally.
dependencies: A mapping from dependency name to ResolvedImage
for
each of the images that image
depends upon.
800 @property 801 def name(self) -> str: 802 """The name of the underlying image.""" 803 return self.image.name
The name of the underlying image.
805 @property 806 def publish(self) -> bool: 807 """Whether the underlying image should be pushed to Docker Hub.""" 808 return self.image.publish
Whether the underlying image should be pushed to Docker Hub.
810 def spec(self) -> str: 811 """Return the "spec" for the image. 812 813 A spec is the unique identifier for the image given its current 814 fingerprint. It is a valid Docker Hub name. 815 """ 816 return self.image.docker_name(tag=f"mzbuild-{self.fingerprint()}")
Return the "spec" for the image.
A spec is the unique identifier for the image given its current fingerprint. It is a valid Docker Hub name.
818 def write_dockerfile(self) -> IO[bytes]: 819 """Render the Dockerfile without mzbuild directives. 820 821 Returns: 822 file: A handle to a temporary file containing the adjusted 823 Dockerfile.""" 824 with open(self.image.path / "Dockerfile", "rb") as f: 825 lines = f.readlines() 826 f = TemporaryFile() 827 for line in lines: 828 match = Image._DOCKERFILE_MZFROM_RE.match(line) 829 if match: 830 image = match.group(1).decode() 831 spec = self.dependencies[image].spec() 832 line = Image._DOCKERFILE_MZFROM_RE.sub(b"FROM %b" % spec.encode(), line) 833 f.write(line) 834 f.seek(0) 835 return f
Render the Dockerfile without mzbuild directives.
Returns: file: A handle to a temporary file containing the adjusted Dockerfile.
837 def build(self, prep: dict[type[PreImage], Any]) -> None: 838 """Build the image from source. 839 840 Requires that the caller has already acquired all dependencies and 841 prepared all `PreImage` actions via `PreImage.prepare_batch`. 842 """ 843 ui.section(f"Building {self.spec()}") 844 spawn.runv(["git", "clean", "-ffdX", self.image.path]) 845 846 for pre_image in self.image.pre_images: 847 pre_image.run(prep[type(pre_image)]) 848 build_args = { 849 **self.image.build_args, 850 "ARCH_GCC": str(self.image.rd.arch), 851 "ARCH_GO": self.image.rd.arch.go_str(), 852 "CI_SANITIZER": str(self.image.rd.sanitizer), 853 } 854 f = self.write_dockerfile() 855 cmd: Sequence[str] = [ 856 "docker", 857 "build", 858 "-f", 859 "-", 860 *(f"--build-arg={k}={v}" for k, v in build_args.items()), 861 "-t", 862 self.spec(), 863 f"--platform=linux/{self.image.rd.arch.go_str()}", 864 str(self.image.path), 865 ] 866 spawn.runv(cmd, stdin=f, stdout=sys.stderr.buffer)
Build the image from source.
Requires that the caller has already acquired all dependencies and
prepared all PreImage
actions via PreImage.prepare_batch
.
868 def try_pull(self, max_retries: int) -> bool: 869 """Download the image if it does not exist locally. Returns whether it was found.""" 870 ui.header(f"Acquiring {self.spec()}") 871 command = ["docker", "pull"] 872 # --quiet skips printing the progress bar, which does not display well in CI. 873 if ui.env_is_truthy("CI"): 874 command.append("--quiet") 875 command.append(self.spec()) 876 if not self.acquired: 877 sleep_time = 1 878 for retry in range(1, max_retries + 1): 879 try: 880 spawn.runv( 881 command, 882 stdout=sys.stderr.buffer, 883 ) 884 self.acquired = True 885 except subprocess.CalledProcessError: 886 if retry < max_retries: 887 # There seems to be no good way to tell what error 888 # happened based on error code 889 # (https://github.com/docker/cli/issues/538) and we 890 # want to print output directly to terminal. 891 print(f"Retrying in {sleep_time}s ...") 892 time.sleep(sleep_time) 893 sleep_time *= 2 894 continue 895 else: 896 break 897 return self.acquired
Download the image if it does not exist locally. Returns whether it was found.
899 def is_published_if_necessary(self) -> bool: 900 """Report whether the image exists on Docker Hub if it is publishable.""" 901 if self.publish and is_docker_image_pushed(self.spec()): 902 ui.say(f"{self.spec()} already exists") 903 return True 904 return False
Report whether the image exists on Docker Hub if it is publishable.
906 def run( 907 self, 908 args: list[str] = [], 909 docker_args: list[str] = [], 910 env: dict[str, str] = {}, 911 ) -> None: 912 """Run a command in the image. 913 914 Creates a container from the image and runs the command described by 915 `args` in the image. 916 """ 917 envs = [] 918 for key, val in env.items(): 919 envs.extend(["--env", f"{key}={val}"]) 920 spawn.runv( 921 [ 922 "docker", 923 "run", 924 "--tty", 925 "--rm", 926 *envs, 927 "--init", 928 *docker_args, 929 self.spec(), 930 *args, 931 ], 932 )
Run a command in the image.
Creates a container from the image and runs the command described by
args
in the image.
942 def inputs(self, transitive: bool = False) -> set[str]: 943 """List the files tracked as inputs to the image. 944 945 These files are used to compute the fingerprint for the image. See 946 `ResolvedImage.fingerprint` for details. 947 948 Returns: 949 inputs: A list of input files, relative to the root of the 950 repository. 951 """ 952 paths = set(git.expand_globs(self.image.rd.root, f"{self.image.path}/**")) 953 if not paths: 954 # While we could find an `mzbuild.yml` file for this service, expland_globs didn't 955 # return any files that matched this service. At the very least, the `mzbuild.yml` 956 # file itself should have been returned. We have a bug if paths is empty. 957 raise AssertionError( 958 f"{self.image.name} mzbuild exists but its files are unknown to git" 959 ) 960 for pre_image in self.image.pre_images: 961 paths |= pre_image.inputs() 962 if transitive: 963 for dep in self.dependencies.values(): 964 paths |= dep.inputs(transitive) 965 return paths
List the files tracked as inputs to the image.
These files are used to compute the fingerprint for the image. See
ResolvedImage.fingerprint
for details.
Returns: inputs: A list of input files, relative to the root of the repository.
967 @cache 968 def fingerprint(self) -> Fingerprint: 969 """Fingerprint the inputs to the image. 970 971 Compute the fingerprint of the image. Changing the contents of any of 972 the files or adding or removing files to the image will change the 973 fingerprint, as will modifying the inputs to any of its dependencies. 974 975 The image considers all non-gitignored files in its mzbuild context to 976 be inputs. If it has a pre-image action, that action may add additional 977 inputs via `PreImage.inputs`. 978 """ 979 self_hash = hashlib.sha1() 980 for rel_path in sorted( 981 set(git.expand_globs(self.image.rd.root, *self.inputs())) 982 ): 983 abs_path = self.image.rd.root / rel_path 984 file_hash = hashlib.sha1() 985 raw_file_mode = os.lstat(abs_path).st_mode 986 # Compute a simplified file mode using the same rules as Git. 987 # https://github.com/git/git/blob/3bab5d562/Documentation/git-fast-import.txt#L610-L616 988 if stat.S_ISLNK(raw_file_mode): 989 file_mode = 0o120000 990 elif raw_file_mode & stat.S_IXUSR: 991 file_mode = 0o100755 992 else: 993 file_mode = 0o100644 994 with open(abs_path, "rb") as f: 995 file_hash.update(f.read()) 996 self_hash.update(file_mode.to_bytes(2, byteorder="big")) 997 self_hash.update(rel_path.encode()) 998 self_hash.update(file_hash.digest()) 999 self_hash.update(b"\0") 1000 1001 for pre_image in self.image.pre_images: 1002 self_hash.update(pre_image.extra().encode()) 1003 self_hash.update(b"\0") 1004 1005 self_hash.update(f"arch={self.image.rd.arch}".encode()) 1006 self_hash.update(f"coverage={self.image.rd.coverage}".encode()) 1007 self_hash.update(f"sanitizer={self.image.rd.sanitizer}".encode()) 1008 1009 full_hash = hashlib.sha1() 1010 full_hash.update(self_hash.digest()) 1011 for dep in sorted(self.dependencies.values(), key=lambda d: d.name): 1012 full_hash.update(dep.name.encode()) 1013 full_hash.update(dep.fingerprint()) 1014 full_hash.update(b"\0") 1015 1016 return Fingerprint(full_hash.digest())
Fingerprint the inputs to the image.
Compute the fingerprint of the image. Changing the contents of any of the files or adding or removing files to the image will change the fingerprint, as will modifying the inputs to any of its dependencies.
The image considers all non-gitignored files in its mzbuild context to
be inputs. If it has a pre-image action, that action may add additional
inputs via PreImage.inputs
.
1019class DependencySet: 1020 """A set of `ResolvedImage`s. 1021 1022 Iterating over a dependency set yields the contained images in an arbitrary 1023 order. Indexing a dependency set yields the image with the specified name. 1024 """ 1025 1026 def __init__(self, dependencies: Iterable[Image]): 1027 """Construct a new `DependencySet`. 1028 1029 The provided `dependencies` must be topologically sorted. 1030 """ 1031 self._dependencies: dict[str, ResolvedImage] = {} 1032 known_images = docker_images() 1033 for d in dependencies: 1034 image = ResolvedImage( 1035 image=d, 1036 dependencies=(self._dependencies[d0] for d0 in d.depends_on), 1037 ) 1038 image.acquired = image.spec() in known_images 1039 self._dependencies[d.name] = image 1040 1041 def _prepare_batch(self, images: list[ResolvedImage]) -> dict[type[PreImage], Any]: 1042 pre_images = collections.defaultdict(list) 1043 for image in images: 1044 for pre_image in image.image.pre_images: 1045 pre_images[type(pre_image)].append(pre_image) 1046 pre_image_prep = {} 1047 for cls, instances in pre_images.items(): 1048 pre_image = cast(PreImage, cls) 1049 pre_image_prep[cls] = pre_image.prepare_batch(instances) 1050 return pre_image_prep 1051 1052 def acquire(self, max_retries: int | None = None) -> None: 1053 """Download or build all of the images in the dependency set that do not 1054 already exist locally. 1055 1056 Args: 1057 max_retries: Number of retries on failure. 1058 """ 1059 1060 # Only retry in CI runs since we struggle with flaky docker pulls there 1061 if not max_retries: 1062 max_retries = 5 if ui.env_is_truthy("CI") else 1 1063 assert max_retries > 0 1064 1065 deps_to_build = [ 1066 dep for dep in self if not dep.publish or not dep.try_pull(max_retries) 1067 ] 1068 1069 # Don't attempt to build in CI, as our timeouts and small machines won't allow it anyway 1070 if ui.env_is_truthy("CI"): 1071 expected_deps = [dep for dep in deps_to_build if dep.publish] 1072 if expected_deps: 1073 print( 1074 f"+++ Expected builds to be available, the build probably failed, so not proceeding: {expected_deps}" 1075 ) 1076 sys.exit(5) 1077 1078 prep = self._prepare_batch(deps_to_build) 1079 for dep in deps_to_build: 1080 dep.build(prep) 1081 1082 def ensure(self, post_build: Callable[[ResolvedImage], None] | None = None): 1083 """Ensure all publishable images in this dependency set exist on Docker 1084 Hub. 1085 1086 Images are pushed using their spec as their tag. 1087 1088 Args: 1089 post_build: A callback to invoke with each dependency that was built 1090 locally. 1091 """ 1092 deps_to_build = [dep for dep in self if not dep.is_published_if_necessary()] 1093 prep = self._prepare_batch(deps_to_build) 1094 1095 images_to_push = [] 1096 for dep in deps_to_build: 1097 dep.build(prep) 1098 if post_build: 1099 post_build(dep) 1100 if dep.publish: 1101 images_to_push.append(dep.spec()) 1102 1103 # Push all Docker images in parallel to minimize build time. 1104 ui.section("Pushing images") 1105 # Attempt to upload images a maximum of 3 times before giving up. 1106 for attempts_remaining in reversed(range(3)): 1107 pushes: list[subprocess.Popen] = [] 1108 for image in images_to_push: 1109 # Piping through `cat` disables terminal control codes, and so the 1110 # interleaved progress output from multiple pushes is less hectic. 1111 # We don't use `docker push --quiet`, as that disables progress 1112 # output entirely. Use `set -o pipefail` so the return code of 1113 # `docker push` is passed through. 1114 push = subprocess.Popen( 1115 [ 1116 "/bin/bash", 1117 "-c", 1118 f"set -o pipefail; docker push {shlex.quote(image)} | cat", 1119 ] 1120 ) 1121 pushes.append(push) 1122 1123 for i, push in reversed(list(enumerate(pushes))): 1124 returncode = push.wait() 1125 if returncode: 1126 if attempts_remaining == 0: 1127 # Last attempt, fail 1128 raise subprocess.CalledProcessError(returncode, push.args) 1129 else: 1130 print(f"docker push {push.args} failed: {returncode}") 1131 else: 1132 del images_to_push[i] 1133 1134 if images_to_push: 1135 time.sleep(10) 1136 print("Retrying in 10 seconds") 1137 1138 def check(self) -> bool: 1139 """Check all publishable images in this dependency set exist on Docker 1140 Hub. Don't try to download or build them.""" 1141 num_deps = len(list(self)) 1142 if num_deps == 0: 1143 return True 1144 with ThreadPoolExecutor(max_workers=num_deps) as executor: 1145 results = list( 1146 executor.map(lambda dep: dep.is_published_if_necessary(), list(self)) 1147 ) 1148 return all(results) 1149 1150 def __iter__(self) -> Iterator[ResolvedImage]: 1151 return iter(self._dependencies.values()) 1152 1153 def __getitem__(self, key: str) -> ResolvedImage: 1154 return self._dependencies[key]
A set of ResolvedImage
s.
Iterating over a dependency set yields the contained images in an arbitrary order. Indexing a dependency set yields the image with the specified name.
1026 def __init__(self, dependencies: Iterable[Image]): 1027 """Construct a new `DependencySet`. 1028 1029 The provided `dependencies` must be topologically sorted. 1030 """ 1031 self._dependencies: dict[str, ResolvedImage] = {} 1032 known_images = docker_images() 1033 for d in dependencies: 1034 image = ResolvedImage( 1035 image=d, 1036 dependencies=(self._dependencies[d0] for d0 in d.depends_on), 1037 ) 1038 image.acquired = image.spec() in known_images 1039 self._dependencies[d.name] = image
Construct a new DependencySet
.
The provided dependencies
must be topologically sorted.
1052 def acquire(self, max_retries: int | None = None) -> None: 1053 """Download or build all of the images in the dependency set that do not 1054 already exist locally. 1055 1056 Args: 1057 max_retries: Number of retries on failure. 1058 """ 1059 1060 # Only retry in CI runs since we struggle with flaky docker pulls there 1061 if not max_retries: 1062 max_retries = 5 if ui.env_is_truthy("CI") else 1 1063 assert max_retries > 0 1064 1065 deps_to_build = [ 1066 dep for dep in self if not dep.publish or not dep.try_pull(max_retries) 1067 ] 1068 1069 # Don't attempt to build in CI, as our timeouts and small machines won't allow it anyway 1070 if ui.env_is_truthy("CI"): 1071 expected_deps = [dep for dep in deps_to_build if dep.publish] 1072 if expected_deps: 1073 print( 1074 f"+++ Expected builds to be available, the build probably failed, so not proceeding: {expected_deps}" 1075 ) 1076 sys.exit(5) 1077 1078 prep = self._prepare_batch(deps_to_build) 1079 for dep in deps_to_build: 1080 dep.build(prep)
Download or build all of the images in the dependency set that do not already exist locally.
Args: max_retries: Number of retries on failure.
1082 def ensure(self, post_build: Callable[[ResolvedImage], None] | None = None): 1083 """Ensure all publishable images in this dependency set exist on Docker 1084 Hub. 1085 1086 Images are pushed using their spec as their tag. 1087 1088 Args: 1089 post_build: A callback to invoke with each dependency that was built 1090 locally. 1091 """ 1092 deps_to_build = [dep for dep in self if not dep.is_published_if_necessary()] 1093 prep = self._prepare_batch(deps_to_build) 1094 1095 images_to_push = [] 1096 for dep in deps_to_build: 1097 dep.build(prep) 1098 if post_build: 1099 post_build(dep) 1100 if dep.publish: 1101 images_to_push.append(dep.spec()) 1102 1103 # Push all Docker images in parallel to minimize build time. 1104 ui.section("Pushing images") 1105 # Attempt to upload images a maximum of 3 times before giving up. 1106 for attempts_remaining in reversed(range(3)): 1107 pushes: list[subprocess.Popen] = [] 1108 for image in images_to_push: 1109 # Piping through `cat` disables terminal control codes, and so the 1110 # interleaved progress output from multiple pushes is less hectic. 1111 # We don't use `docker push --quiet`, as that disables progress 1112 # output entirely. Use `set -o pipefail` so the return code of 1113 # `docker push` is passed through. 1114 push = subprocess.Popen( 1115 [ 1116 "/bin/bash", 1117 "-c", 1118 f"set -o pipefail; docker push {shlex.quote(image)} | cat", 1119 ] 1120 ) 1121 pushes.append(push) 1122 1123 for i, push in reversed(list(enumerate(pushes))): 1124 returncode = push.wait() 1125 if returncode: 1126 if attempts_remaining == 0: 1127 # Last attempt, fail 1128 raise subprocess.CalledProcessError(returncode, push.args) 1129 else: 1130 print(f"docker push {push.args} failed: {returncode}") 1131 else: 1132 del images_to_push[i] 1133 1134 if images_to_push: 1135 time.sleep(10) 1136 print("Retrying in 10 seconds")
Ensure all publishable images in this dependency set exist on Docker Hub.
Images are pushed using their spec as their tag.
Args: post_build: A callback to invoke with each dependency that was built locally.
1138 def check(self) -> bool: 1139 """Check all publishable images in this dependency set exist on Docker 1140 Hub. Don't try to download or build them.""" 1141 num_deps = len(list(self)) 1142 if num_deps == 0: 1143 return True 1144 with ThreadPoolExecutor(max_workers=num_deps) as executor: 1145 results = list( 1146 executor.map(lambda dep: dep.is_published_if_necessary(), list(self)) 1147 ) 1148 return all(results)
Check all publishable images in this dependency set exist on Docker Hub. Don't try to download or build them.
1157class Repository: 1158 """A collection of mzbuild `Image`s. 1159 1160 Creating a repository will walk the filesystem beneath `root` to 1161 automatically discover all contained `Image`s. 1162 1163 Iterating over a repository yields the contained images in an arbitrary 1164 order. 1165 1166 Args: 1167 root: The path to the root of the repository. 1168 arch: The CPU architecture to build for. 1169 profile: What profile to build the repository in. 1170 coverage: Whether to enable code coverage instrumentation. 1171 sanitizer: Whether to a sanitizer (address, thread, leak, memory, none) 1172 image_registry: The Docker image registry to pull images from and push 1173 images to. 1174 image_prefix: A prefix to apply to all Docker image names. 1175 1176 Attributes: 1177 images: A mapping from image name to `Image` for all contained images. 1178 compose_dirs: The set of directories containing a `mzcompose.py` file. 1179 """ 1180 1181 def __init__( 1182 self, 1183 root: Path, 1184 arch: Arch = Arch.host(), 1185 profile: Profile = Profile.RELEASE, 1186 coverage: bool = False, 1187 sanitizer: Sanitizer = Sanitizer.none, 1188 image_registry: str = "materialize", 1189 image_prefix: str = "", 1190 bazel: bool = False, 1191 bazel_remote_cache: str | None = None, 1192 ): 1193 self.rd = RepositoryDetails( 1194 root, 1195 arch, 1196 profile, 1197 coverage, 1198 sanitizer, 1199 image_registry, 1200 image_prefix, 1201 bazel, 1202 bazel_remote_cache, 1203 ) 1204 self.images: dict[str, Image] = {} 1205 self.compositions: dict[str, Path] = {} 1206 for path, dirs, files in os.walk(self.root, topdown=True): 1207 if path == str(root / "misc"): 1208 dirs.remove("python") 1209 # Filter out some particularly massive ignored directories to keep 1210 # things snappy. Not required for correctness. 1211 dirs[:] = set(dirs) - { 1212 ".git", 1213 ".mypy_cache", 1214 "target", 1215 "target-ra", 1216 "target-xcompile", 1217 "mzdata", 1218 "node_modules", 1219 "venv", 1220 } 1221 if "mzbuild.yml" in files: 1222 image = Image(self.rd, Path(path)) 1223 if not image.name: 1224 raise ValueError(f"config at {path} missing name") 1225 if image.name in self.images: 1226 raise ValueError(f"image {image.name} exists twice") 1227 self.images[image.name] = image 1228 if "mzcompose.py" in files: 1229 name = Path(path).name 1230 if name in self.compositions: 1231 raise ValueError(f"composition {name} exists twice") 1232 self.compositions[name] = Path(path) 1233 1234 # Validate dependencies. 1235 for image in self.images.values(): 1236 for d in image.depends_on: 1237 if d not in self.images: 1238 raise ValueError( 1239 f"image {image.name} depends on non-existent image {d}" 1240 ) 1241 1242 @staticmethod 1243 def install_arguments(parser: argparse.ArgumentParser) -> None: 1244 """Install options to configure a repository into an argparse parser. 1245 1246 This function installs the following options: 1247 1248 * The mutually-exclusive `--dev`/`--optimized`/`--release` options to control the 1249 `profile` repository attribute. 1250 * The `--coverage` boolean option to control the `coverage` repository 1251 attribute. 1252 1253 Use `Repository.from_arguments` to construct a repository from the 1254 parsed command-line arguments. 1255 """ 1256 build_mode = parser.add_mutually_exclusive_group() 1257 build_mode.add_argument( 1258 "--dev", 1259 action="store_true", 1260 help="build Rust binaries with the dev profile", 1261 ) 1262 build_mode.add_argument( 1263 "--release", 1264 action="store_true", 1265 help="build Rust binaries with the release profile (default)", 1266 ) 1267 build_mode.add_argument( 1268 "--optimized", 1269 action="store_true", 1270 help="build Rust binaries with the optimized profile (optimizations, no LTO, no debug symbols)", 1271 ) 1272 parser.add_argument( 1273 "--coverage", 1274 help="whether to enable code coverage compilation flags", 1275 default=ui.env_is_truthy("CI_COVERAGE_ENABLED"), 1276 action="store_true", 1277 ) 1278 parser.add_argument( 1279 "--sanitizer", 1280 help="whether to enable a sanitizer", 1281 default=Sanitizer[os.getenv("CI_SANITIZER", "none")], 1282 type=Sanitizer, 1283 choices=Sanitizer, 1284 ) 1285 parser.add_argument( 1286 "--arch", 1287 default=Arch.host(), 1288 help="the CPU architecture to build for", 1289 type=Arch, 1290 choices=Arch, 1291 ) 1292 parser.add_argument( 1293 "--image-registry", 1294 default="materialize", 1295 help="the Docker image registry to pull images from and push images to", 1296 ) 1297 parser.add_argument( 1298 "--image-prefix", 1299 default="", 1300 help="a prefix to apply to all Docker image names", 1301 ) 1302 parser.add_argument( 1303 "--bazel", 1304 default=ui.env_is_truthy("CI_BAZEL_BUILD"), 1305 action="store_true", 1306 ) 1307 parser.add_argument( 1308 "--bazel-remote-cache", 1309 default=os.getenv("CI_BAZEL_REMOTE_CACHE"), 1310 action="store", 1311 ) 1312 1313 @classmethod 1314 def from_arguments(cls, root: Path, args: argparse.Namespace) -> "Repository": 1315 """Construct a repository from command-line arguments. 1316 1317 The provided namespace must contain the options installed by 1318 `Repository.install_arguments`. 1319 """ 1320 if args.release: 1321 profile = Profile.RELEASE 1322 elif args.optimized: 1323 profile = Profile.OPTIMIZED 1324 elif args.dev: 1325 profile = Profile.DEV 1326 else: 1327 profile = Profile.RELEASE 1328 1329 return cls( 1330 root, 1331 profile=profile, 1332 coverage=args.coverage, 1333 sanitizer=args.sanitizer, 1334 image_registry=args.image_registry, 1335 image_prefix=args.image_prefix, 1336 arch=args.arch, 1337 bazel=args.bazel, 1338 bazel_remote_cache=args.bazel_remote_cache, 1339 ) 1340 1341 @property 1342 def root(self) -> Path: 1343 """The path to the root directory for the repository.""" 1344 return self.rd.root 1345 1346 def resolve_dependencies(self, targets: Iterable[Image]) -> DependencySet: 1347 """Compute the dependency set necessary to build target images. 1348 1349 The dependencies of `targets` will be crawled recursively until the 1350 complete set of transitive dependencies is determined or a circular 1351 dependency is discovered. The returned dependency set will be sorted 1352 in topological order. 1353 1354 Raises: 1355 ValueError: A circular dependency was discovered in the images 1356 in the repository. 1357 """ 1358 resolved = OrderedDict() 1359 visiting = set() 1360 1361 def visit(image: Image, path: list[str] = []) -> None: 1362 if image.name in resolved: 1363 return 1364 if image.name in visiting: 1365 diagram = " -> ".join(path + [image.name]) 1366 raise ValueError(f"circular dependency in mzbuild: {diagram}") 1367 1368 visiting.add(image.name) 1369 for d in sorted(image.depends_on): 1370 visit(self.images[d], path + [image.name]) 1371 resolved[image.name] = image 1372 1373 for target_image in sorted(targets, key=lambda image: image.name): 1374 visit(target_image) 1375 1376 return DependencySet(resolved.values()) 1377 1378 def __iter__(self) -> Iterator[Image]: 1379 return iter(self.images.values())
A collection of mzbuild Image
s.
Creating a repository will walk the filesystem beneath root
to
automatically discover all contained Image
s.
Iterating over a repository yields the contained images in an arbitrary order.
Args: root: The path to the root of the repository. arch: The CPU architecture to build for. profile: What profile to build the repository in. coverage: Whether to enable code coverage instrumentation. sanitizer: Whether to a sanitizer (address, thread, leak, memory, none) image_registry: The Docker image registry to pull images from and push images to. image_prefix: A prefix to apply to all Docker image names.
Attributes:
images: A mapping from image name to Image
for all contained images.
compose_dirs: The set of directories containing a mzcompose.py
file.
1181 def __init__( 1182 self, 1183 root: Path, 1184 arch: Arch = Arch.host(), 1185 profile: Profile = Profile.RELEASE, 1186 coverage: bool = False, 1187 sanitizer: Sanitizer = Sanitizer.none, 1188 image_registry: str = "materialize", 1189 image_prefix: str = "", 1190 bazel: bool = False, 1191 bazel_remote_cache: str | None = None, 1192 ): 1193 self.rd = RepositoryDetails( 1194 root, 1195 arch, 1196 profile, 1197 coverage, 1198 sanitizer, 1199 image_registry, 1200 image_prefix, 1201 bazel, 1202 bazel_remote_cache, 1203 ) 1204 self.images: dict[str, Image] = {} 1205 self.compositions: dict[str, Path] = {} 1206 for path, dirs, files in os.walk(self.root, topdown=True): 1207 if path == str(root / "misc"): 1208 dirs.remove("python") 1209 # Filter out some particularly massive ignored directories to keep 1210 # things snappy. Not required for correctness. 1211 dirs[:] = set(dirs) - { 1212 ".git", 1213 ".mypy_cache", 1214 "target", 1215 "target-ra", 1216 "target-xcompile", 1217 "mzdata", 1218 "node_modules", 1219 "venv", 1220 } 1221 if "mzbuild.yml" in files: 1222 image = Image(self.rd, Path(path)) 1223 if not image.name: 1224 raise ValueError(f"config at {path} missing name") 1225 if image.name in self.images: 1226 raise ValueError(f"image {image.name} exists twice") 1227 self.images[image.name] = image 1228 if "mzcompose.py" in files: 1229 name = Path(path).name 1230 if name in self.compositions: 1231 raise ValueError(f"composition {name} exists twice") 1232 self.compositions[name] = Path(path) 1233 1234 # Validate dependencies. 1235 for image in self.images.values(): 1236 for d in image.depends_on: 1237 if d not in self.images: 1238 raise ValueError( 1239 f"image {image.name} depends on non-existent image {d}" 1240 )
1242 @staticmethod 1243 def install_arguments(parser: argparse.ArgumentParser) -> None: 1244 """Install options to configure a repository into an argparse parser. 1245 1246 This function installs the following options: 1247 1248 * The mutually-exclusive `--dev`/`--optimized`/`--release` options to control the 1249 `profile` repository attribute. 1250 * The `--coverage` boolean option to control the `coverage` repository 1251 attribute. 1252 1253 Use `Repository.from_arguments` to construct a repository from the 1254 parsed command-line arguments. 1255 """ 1256 build_mode = parser.add_mutually_exclusive_group() 1257 build_mode.add_argument( 1258 "--dev", 1259 action="store_true", 1260 help="build Rust binaries with the dev profile", 1261 ) 1262 build_mode.add_argument( 1263 "--release", 1264 action="store_true", 1265 help="build Rust binaries with the release profile (default)", 1266 ) 1267 build_mode.add_argument( 1268 "--optimized", 1269 action="store_true", 1270 help="build Rust binaries with the optimized profile (optimizations, no LTO, no debug symbols)", 1271 ) 1272 parser.add_argument( 1273 "--coverage", 1274 help="whether to enable code coverage compilation flags", 1275 default=ui.env_is_truthy("CI_COVERAGE_ENABLED"), 1276 action="store_true", 1277 ) 1278 parser.add_argument( 1279 "--sanitizer", 1280 help="whether to enable a sanitizer", 1281 default=Sanitizer[os.getenv("CI_SANITIZER", "none")], 1282 type=Sanitizer, 1283 choices=Sanitizer, 1284 ) 1285 parser.add_argument( 1286 "--arch", 1287 default=Arch.host(), 1288 help="the CPU architecture to build for", 1289 type=Arch, 1290 choices=Arch, 1291 ) 1292 parser.add_argument( 1293 "--image-registry", 1294 default="materialize", 1295 help="the Docker image registry to pull images from and push images to", 1296 ) 1297 parser.add_argument( 1298 "--image-prefix", 1299 default="", 1300 help="a prefix to apply to all Docker image names", 1301 ) 1302 parser.add_argument( 1303 "--bazel", 1304 default=ui.env_is_truthy("CI_BAZEL_BUILD"), 1305 action="store_true", 1306 ) 1307 parser.add_argument( 1308 "--bazel-remote-cache", 1309 default=os.getenv("CI_BAZEL_REMOTE_CACHE"), 1310 action="store", 1311 )
Install options to configure a repository into an argparse parser.
This function installs the following options:
- The mutually-exclusive
--dev
/--optimized
/--release
options to control theprofile
repository attribute. - The
--coverage
boolean option to control thecoverage
repository attribute.
Use Repository.from_arguments
to construct a repository from the
parsed command-line arguments.
1313 @classmethod 1314 def from_arguments(cls, root: Path, args: argparse.Namespace) -> "Repository": 1315 """Construct a repository from command-line arguments. 1316 1317 The provided namespace must contain the options installed by 1318 `Repository.install_arguments`. 1319 """ 1320 if args.release: 1321 profile = Profile.RELEASE 1322 elif args.optimized: 1323 profile = Profile.OPTIMIZED 1324 elif args.dev: 1325 profile = Profile.DEV 1326 else: 1327 profile = Profile.RELEASE 1328 1329 return cls( 1330 root, 1331 profile=profile, 1332 coverage=args.coverage, 1333 sanitizer=args.sanitizer, 1334 image_registry=args.image_registry, 1335 image_prefix=args.image_prefix, 1336 arch=args.arch, 1337 bazel=args.bazel, 1338 bazel_remote_cache=args.bazel_remote_cache, 1339 )
Construct a repository from command-line arguments.
The provided namespace must contain the options installed by
Repository.install_arguments
.
1341 @property 1342 def root(self) -> Path: 1343 """The path to the root directory for the repository.""" 1344 return self.rd.root
The path to the root directory for the repository.
1346 def resolve_dependencies(self, targets: Iterable[Image]) -> DependencySet: 1347 """Compute the dependency set necessary to build target images. 1348 1349 The dependencies of `targets` will be crawled recursively until the 1350 complete set of transitive dependencies is determined or a circular 1351 dependency is discovered. The returned dependency set will be sorted 1352 in topological order. 1353 1354 Raises: 1355 ValueError: A circular dependency was discovered in the images 1356 in the repository. 1357 """ 1358 resolved = OrderedDict() 1359 visiting = set() 1360 1361 def visit(image: Image, path: list[str] = []) -> None: 1362 if image.name in resolved: 1363 return 1364 if image.name in visiting: 1365 diagram = " -> ".join(path + [image.name]) 1366 raise ValueError(f"circular dependency in mzbuild: {diagram}") 1367 1368 visiting.add(image.name) 1369 for d in sorted(image.depends_on): 1370 visit(self.images[d], path + [image.name]) 1371 resolved[image.name] = image 1372 1373 for target_image in sorted(targets, key=lambda image: image.name): 1374 visit(target_image) 1375 1376 return DependencySet(resolved.values())
Compute the dependency set necessary to build target images.
The dependencies of targets
will be crawled recursively until the
complete set of transitive dependencies is determined or a circular
dependency is discovered. The returned dependency set will be sorted
in topological order.
Raises: ValueError: A circular dependency was discovered in the images in the repository.
1382def publish_multiarch_images( 1383 tag: str, dependency_sets: Iterable[Iterable[ResolvedImage]] 1384) -> None: 1385 """Publishes a set of docker images under a given tag.""" 1386 for images in zip(*dependency_sets): 1387 names = set(image.image.name for image in images) 1388 assert len(names) == 1, "dependency sets did not contain identical images" 1389 name = images[0].image.docker_name(tag) 1390 spawn.runv( 1391 ["docker", "manifest", "create", name, *(image.spec() for image in images)] 1392 ) 1393 spawn.runv(["docker", "manifest", "push", name]) 1394 print(f"--- Nofifying for tag {tag}") 1395 markdown = f"""Pushed images with Docker tag `{tag}`""" 1396 spawn.runv( 1397 [ 1398 "buildkite-agent", 1399 "annotate", 1400 "--style=info", 1401 f"--context=build-tags-{tag}", 1402 ], 1403 stdin=markdown.encode(), 1404 )
Publishes a set of docker images under a given tag.