-
Notifications
You must be signed in to change notification settings - Fork 97
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New script to manually push multi-platform docker images (#6954)
<!-- 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
Showing
4 changed files
with
378 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,3 +60,6 @@ enterprise/config/user-configs | |
|
||
# IntelliJ directory | ||
/.ijwb/ | ||
|
||
# Python cache | ||
**/__pycache__/** |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
112
enterprise/tools/build_images/lib/check_buildx_support.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |