Skip to content

Commit

Permalink
New script to manually push multi-platform docker images (#6954)
Browse files Browse the repository at this point in the history
<!-- Optional: Provide additional context (beyond the PR title). -->

Since `rules_docker` is no longer supported, we are looking at moving to
`rules_oci`. However, `rules_oci` doesn't support building images with a
dockerfile since it's not hermetic. Instead, they recommend building
your base image manually and uploading it to a registry, and then
pulling it down by hash during your bazel build. This is a script to
assist in the process of building such images. It's likely that instead
of invoking it directly, we can make one-line bash scripts that do the
pushes we want for us. I think another option for further work would be
adding a warning about pushing an image to a prod repository for which
there does not exist a corresponding dev repository.

<!-- Optional: link a GitHub issue.
Example: "Fixes #123" will auto-close #123 when the PR is merged. -->

**Related issues**: N/A
  • Loading branch information
tempoz authored Feb 5, 2025
1 parent d6ad168 commit 06fd771
Show file tree
Hide file tree
Showing 4 changed files with 378 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ enterprise/config/user-configs

# IntelliJ directory
/.ijwb/

# Python cache
**/__pycache__/**
263 changes: 263 additions & 0 deletions enterprise/tools/build_images/build_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
Script to build multi-platform docker images.
Example command usage:
./enterprise/tools/build_images/build_image.py --registry=gcr.io --repository=flame-public/rbe-ubuntu22-04 --tag=latest --no-suffix --dockerfile=dockerfiles/rbe-ubuntu22-04/Dockerfile --platform=amd64 --platform=arm64 --user=_token
"""

import argparse
import getpass
import json
import os
import requests
import subprocess
import sys

from lib.check_buildx_support import check_buildx_support

def parse_program_arguments():
"""Parses the program arguments and returns a namespace representing them."""
class ArgsNamespace(argparse.Namespace):
registry: str
repository: str
tag: str
dockerfile: str
context_dir: str
buildx_container_name: str
platforms: list[str]
userpass: str
do_login: bool
push: bool
do_new_repository_check: bool
do_prod_checks: bool
suffix: str

parser = argparse.ArgumentParser()
parser.add_argument(
"--registry",
default="gcr.io/flame-build",
type=str,
help="Container registry where the target repository is located. (Default: gcr.io/flame-build)",
dest="registry",
)
parser.add_argument(
"--repository",
type=str,
required=True,
help="Name of the target repository in the container registry ('-dev' and '-prod' suffixes should be appended automatically).",
dest="repository",
)
parser.add_argument(
"--tag",
type=str,
help="Tag to use in the target repository for the built image (if any).",
dest="tag",
)
parser.add_argument(
"--dockerfile",
default="Dockerfile",
type=str,
help="Path to the dockerfile to use when building the image. (Default: Dockerfile)",
dest="dockerfile",
)
parser.add_argument(
"--context-dir",
type=str,
help="Path to the context directory to use when building the image. Defaults to the dockerfile directory if not provided.",
dest="context_dir",
)
parser.add_argument(
"--buildx-container-name",
default="buildbuddy-buildx",
type=str,
help="Name of buildx container to use for building the images. If a buildx container with that name does not yet exist, it will be created.",
dest="buildx_container_name",
)
parser.add_argument(
"--platform",
action="append",
type=str,
help="Repeated option. Set this to specify platforms to build the image for.",
metavar="PLATFORM",
dest="platforms",
)
parser.add_argument(
"--user",
type=str,
help="User name for the registry; this should be of the form user[:password]. If the password is missing, the script will prompt for the password via stdin. If this option is specified, the script will attempt to `docker login` to the registry unless '--skip-login' is also specified. gcloud users will want to use '_dcgcloud_token' as the user and the output of `gcloud auth print-access-token` as the password.",
dest="userpass",
)
parser.add_argument(
"--skip-login",
action="store_false",
help="Skip the `docker login` step. This has no effect if '--user' has not been specified.",
dest="do_login",
)
parser.add_argument(
"--no-push",
action="store_false",
help="Set to only build the image and cache it in docker's build cache. To push the built image, run the build command again with '--push'.",
dest="push",
)
parser.add_argument(
"--force-ignore-new-repository-check",
action="store_false",
help="Set to ignore warnings about the repository not currently existing in the registry. Set this if you really are looking to push a repository with an entirely new name, not just a new image to an existing repository.",
dest="do_new_repository_check",
)
parser.add_argument(
"--force-ignore-prod-checks",
action="store_false",
help="*** THIS OPTION IS DANGEROUS, BE SURE YOU KNOW WHAT YOU'RE DOING *** Set to ignore warnings and checks surrounding prod pushes. Recommended only in unmonitored scripts and in the case that the existing prod image is currently broken.",
dest="do_prod_checks",
)
suffix_group = parser.add_argument_group("Suffixes", "Suffixes to be attached to the end of the repository name. Only one may be specified; '--dev' is the default.").add_mutually_exclusive_group()
suffix_group.add_argument(
"--dev",
action="store_const",
const="dev",
default="dev",
help="Adds '-dev' to the end of the repository name. Intended to be used for pushing dev images.",
dest="suffix",
)
suffix_group.add_argument(
"--prod",
action="store_const",
const="prod",
help="Adds '-prod' to the end of the repository name. Intended to be used for pushing prod images.",
dest="suffix",
)
suffix_group.add_argument(
"--no-suffix",
action="store_const",
const="",
help="No suffix will be added to the repository name. You will still receive a warning if you are pushing to a repository name that ends in '-prod'.",
dest="suffix",
)

return parser.parse_args(namespace=ArgsNamespace)

def set_up_buildx_container(container_name, stdout=sys.stdout, stderr=sys.stderr):
"""Sets up a buildx container with the given name."""
completed_process = subprocess.run(["docker", "buildx", "inspect", container_name], capture_output=True)
if completed_process.returncode == 1:
print(f"No buildx container named {container_name}, creating it...", file=stdout)
completed_process = subprocess.run(["docker", "buildx", "create", "--name", container_name])
if completed_process.returncode == 0:
print("Success!", stdout)
else:
print("Failed to create buildx container.", stderr)

def resolve_userpass(userpass: str, registry: str):
"""Returns (user, password), prompting for the password if necessary."""
if not userpass:
return None
user, sep, password = userpass.partition(":")
if sep != ":":
password = getpass.getpass(f"Password for {user} at {registry}: ")
return (user, password)


def yes_no_prompt(prompt, default_yes=False):
"""Prompts the user for a yes or no response. Returns True for "yes" and False for "no"."""
try:
response = input(f"{prompt} ({'Y/n' if default_yes else 'y/N'}): ")
except EOFError:
return default_yes
if not response:
return default_yes
if default_yes:
return not "no".startswith(response.lower())
else:
return "yes".startswith(response.lower())

def perform_prod_checks(repository_path: str):
"""Checks to make sure any push to prod is intentional."""
if repository_path.endswith("-prod"):
return yes_no_prompt(f"Repository path {repository_path} ends in '-prod', indicating that this is a production image. Are you sure you want to push this image to a production repository?")
return True

def perform_new_repository_check(registry: str, repository: str, userpass: tuple[str, str]|None):
"""Checks to make sure any creation of a new repository is intentional."""
resp = requests.get(f"https://{registry}/v2/_catalog", auth=userpass)
try:
content = json.loads(resp.text)
except json.JSONDecodeError:
content = None
if not isinstance(content, dict) or "repositories" not in content or not isinstance(content["repositories"], list):
return yes_no_prompt(f"Attempt to view catalog of {registry} failed; received response: {resp.text}\nCannot confirm that {repository} already exists in {registry}. Would you like to proceed anyway?")
if repository not in content["repositories"]:
return yes_no_prompt(f"Repository {repository} does not yet exist in {registry}. Are you sure you want to create a new repository?")
return True

def perform_login(registry: str, userpass: tuple[str, str]):
"""Executes `docker login`. Returns True for success and False for failure."""
completed_process = subprocess.run(
(
["docker", "login"] +
["-u", userpass[0], "--password-stdin"] +
[f"https://{registry}"]
),
capture_output=True,
input=userpass[1].encode(sys.getdefaultencoding()),
)
if completed_process.returncode != 0:
print(f"docker login failed with exit code {completed_process.returncode}.\nstdout:\n{completed_process.stdout}\nstderr:\n{completed_process.stderr}", file=sys.stderr)
return False
return True


def repeated_opts(opt: str, params: list[str]):
"""Returns a list of the form [opt, params[0], opt, params[1], ... ]"""
return [arg for param in params for arg in (opt, param)]

def main():
args = parse_program_arguments()

userpass = resolve_userpass(args.userpass, args.registry)

if not check_buildx_support():
return 1

context_dir = "."
if args.context_dir:
context_dir = args.context_dir
elif os.path.dirname(os.path.abspath(args.dockerfile)) != "":
context_dir = os.path.dirname(os.path.abspath(args.dockerfile))

repository = f"{args.repository}{'-' + args.suffix if args.suffix else ''}"

if args.push:
if args.do_prod_checks and not perform_prod_checks(repository):
print("Exiting...", file=sys.stdout)
return 0

if args.do_new_repository_check and not perform_new_repository_check(args.registry, repository, userpass):
print("Exiting...", file=sys.stdout)
return 0

if userpass is not None and args.do_login:
if not perform_login(args.registry, userpass):
print("Exiting...", file=sys.stdout)
return 0

set_up_buildx_container(args.buildx_container_name)

docker_build_args = (
["docker", "buildx"] +
["--builder", str(args.buildx_container_name)] +
["build"] +
["--tag", f"{args.registry}/{repository}{':' + args.tag if args.tag else ''}"] +
(["--file", args.dockerfile] if args.dockerfile else []) +
repeated_opts("--platform", args.platforms) +
(["--push"] if args.push else []) +
[context_dir]
)
completed_process = subprocess.run(docker_build_args)
print(f"Executed command:\n{completed_process.args}", file=sys.stdout)
return completed_process.returncode

main()
Empty file.
112 changes: 112 additions & 0 deletions enterprise/tools/build_images/lib/check_buildx_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from packaging import version
import os
import re
import subprocess
import sys

MIN_DOCKER_VERSION = version.parse("19.0.3")
MIN_KERNEL_VERSION = version.parse("4.8")
MIN_BINFMTS_VERSION = version.parse("2.1.7")

def check_buildx_support(stdout=sys.stdout, stderr=sys.stderr):
print("Checking prerequisites...", file=stdout)
# Check to make sure we can use buildx to build the requested platforms
completed_process = subprocess.run(["docker", "--version"], capture_output=True)
if completed_process.returncode != 0:
print("'docker' could not be found. Make sure it is installed and in your PATH.", file=stderr)
return False

match = re.fullmatch(
r"Docker version ([0-9]+(?:[.][0-9]+){0,2})[^,]*, build [0-9]+",
completed_process.stdout.decode(sys.stdout.encoding).strip(),
)
if match != None:
docker_version = version.parse(match.group(1))
if docker_version < MIN_DOCKER_VERSION:
print(f"docker version {docker_version} does not support buildx; need at least docker {MIN_DOCKER_VERSION}.", file=stderr)
return False
else:
print(f"Could not parse docker version; cannot ensure docker is newer than {MIN_DOCKER_VERSION}. If the build fails, check your docker version manually.", file=stderr)

completed_process = subprocess.run(["docker", "buildx"], capture_output=True)
if completed_process.returncode != 0:
completed_process = subprocess.run(["docker", "version"], capture_output=True, check=True)
match = re.search(
r"^\s*Experimental:\s*(true|false)$",
completed_process.stdout.decode(sys.stdout.encoding),
)
if match != None:
if match.group(1) != True:
print("docker buildx not supported by default; try again with env variable DOCKER_CLI_EXPERIMENTAL=enabled", file=stderr)
return False
else:
print("docker buildx not supported even with experimental features enabled; maybe you don't have the buildx plugin installed?", file=stderr)
return False
else:
print("docker buildx not supported. Unable to detect if experimental features are enabled. You may not have the buildx plugin installed, or you may need to set the env variable DOCKER_CLI_EXPERIMENTAL=enabled, or both.", file=stderr)
return False

completed_process = subprocess.run(["uname", "-r"], capture_output=True, check=True)
match = re.fullmatch(
r"([0-9]+(?:[.][0-9]+){0,2}).*",
completed_process.stdout.decode(sys.stdout.encoding).strip(),
)
if match != None:
kernel_version = version.parse(match.group(1))
if kernel_version < MIN_KERNEL_VERSION:
print(f"kernel version {kernel_version} does not have binfmt_misc fix-binary (F) support; need at least kernel version {kernel_version}.", file=stderr)
return False
else:
print(f"Could not parse kernel version; cannot ensure kernel is newer than {MIN_KERNEL_VERSION}. If the build fails, check your kernel version manually.", file=stderr)

completed_process = subprocess.run(["findmnt", "-M", "/proc/sys/fs/binfmt_misc"], capture_output=True)
if completed_process.returncode != 0:
print("proc/sys/fs/binfmt_misc is not mounted; mount with 'sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc'", file=stderr)
return False

completed_process = subprocess.run(["update-binfmts", "--version"], capture_output=True)
if completed_process.returncode != 0:
print("'update-binfmts' could not be found. Make sure it is installed and in your PATH (it may be installed in /usr/sbin).", file=stderr)
return False

match = re.fullmatch(
r"binfmt-support\s+([0-9]+(?:[.][0-9]+){0,2}).*",
completed_process.stdout.decode(sys.stdout.encoding).strip(),
)
if match != None:
binfmts_version = version.parse(match.group(1))
if binfmts_version < MIN_BINFMTS_VERSION:
print(f"update-binfmts version {binfmts_version} does not have fix-binary (F) support; need at least docker {MIN_BINFMTS_VERSION}.", file=stderr)
return False
else:
print(f"Could not parse update-binfmts version; cannot ensure update-binfmts is newer than {MIN_BINFMTS_VERSION}. If the build fails, check your update-binfmts version manually.", file=stderr)

canary_arch = "aarch64"
if not os.path.exists(f"/proc/sys/fs/binfmt_misc/qemu-{canary_arch}"):
if not os.path.exists(f"/usr/bin/qemu-{canary_arch}-static"):
print("QEMU is not installed; install qemu-user-static.", file=stderr)
return False
print("QEMU is not registered with binfmt_misc; you may try manually running qemu-binfmt-conf.sh, available in qemu's GitHub repo.", file=stderr)
return False

with open(f"/proc/sys/fs/binfmt_misc/qemu-{canary_arch}") as f:
match = re.search(
r"^flags:\s*(.*)$",
f.read(),
re.MULTILINE,
)
if match != None:
if "F" not in match.group(1):
print("QEMU is not registered in binfmt_misc with fix-binary (F) flag. Flags: {match.group(1)}", file=stderr)
return False
else:
print(f"Could not find flags in {f.name}; cannot confirm presence of fix-binary (F) flag. If the build fails, check for this flag manually.", file=stderr)

print("Done.", file=stdout)
return True

def main():
return 0 if check_buildx_support() else 1

if __name__ == "__main__":
main()

0 comments on commit 06fd771

Please sign in to comment.