Merge pull request #775 from b-ehlers/multibuild

Docker build: Support building and uploading to multiple registries
This commit is contained in:
Jeremy Grossmann 2023-06-19 13:02:16 +09:30 committed by GitHub
commit 71cdbb7f4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 91 additions and 71 deletions

View File

@ -49,7 +49,7 @@ to the arguments. When using the option -a/--all, all images are
forcibly rebuild, except those specified on the command line.
The environment variable DOCKER_REPOSITORY must be set to the
Docker repository to use.
Docker repository to use for name-only targets.
"""
import os
@ -63,7 +63,7 @@ import dxf
import requests.exceptions
import dateutil.parser
base_images = {}
image_info = {}
images = []
@ -174,27 +174,29 @@ def get_time_layers(repository):
sys.exit(f"{repository}: missing information from registry")
def expand_base_image(base_name):
def expand_base_image(base_name, target):
""" expand base image """
match = re.match(r"\$\{?DOCKER_REPOSITORY\}?/(.+)", base_name)
if not match:
return (base_name, [])
if not docker_env["repository"]:
raise ValueError("Environment variable DOCKER_REPOSITORY "
"is not defined or is empty")
base_name = docker_env["repository"] + "/" + match.group(1)
options = ["--build-arg", "DOCKER_REPOSITORY=" + docker_env["repository"]]
options = []
base_split = base_name.split("/", maxsplit=1)
if len(base_split) == 2 and \
base_split[0] in ("$DOCKER_REPOSITORY", "${DOCKER_REPOSITORY}"):
try:
target_base = target[:target.rindex("/")]
except ValueError as err:
raise ValueError(f"{base_name}: "
f"Invalid target repository {target}") from err
base_name = target_base + "/" + base_split[1]
options = ["--build-arg", "DOCKER_REPOSITORY=" + target_base]
return (base_name, options)
def full_image_name(image_name):
def full_image_name(image_name, default_repository):
""" get full image name """
if "/" in image_name:
return image_name
if not docker_env["repository"]:
raise ValueError("Environment variable DOCKER_REPOSITORY "
"is not defined or is empty")
return docker_env["repository"] + "/" + image_name
if not default_repository:
raise ValueError(f"{image_name}: Missing default repository")
return default_repository + "/" + image_name
def dockerfile_base(directory):
@ -252,16 +254,15 @@ def get_images(image_file):
gbl_options = shlex.split(match.group('gbl_opt'))
if match.group('name') and match.group('dir'):
name = match.group('name')
full_name = full_image_name(name)
try:
parse_repository(full_name)
parse_repository(full_image_name(name, "test.io/test"))
except ValueError:
sys.exit(f"{image_file} line {lineno}: "
f"invalid image name '{full_name}'")
if full_name in name_set:
f"invalid image name '{name}'")
if name in name_set:
sys.exit(f"{image_file}: "
f"multiple entries for {full_name}")
name_set.add(full_name)
f"multiple entries for {name}")
name_set.add(name)
directory = match.group('dir')
if not os.path.isdir(directory):
sys.exit(f"{image_file} line {lineno}: "
@ -269,8 +270,7 @@ def get_images(image_file):
base = match.group('base')
if not base: # extract base repo from Dockerfile
base = dockerfile_base(directory)
(base, options) = expand_base_image(base)
options += gbl_options
options = gbl_options.copy()
if match.group('opt'):
options += shlex.split(match.group('opt'))
images.append({"name": name, "dir": directory,
@ -283,14 +283,10 @@ def get_images(image_file):
sys.exit("Empty image configuration")
def init_base_images():
def init_image_info():
""" initialize base image data structure """
for image in images:
base_name = image["base"]
if base_name not in base_images:
base_images[base_name] = {"layer": False}
base_images["scratch"] = {"layer": None}
base_images["NONE"] = {"layer": None}
image_info["scratch"] = None
image_info["NONE"] = None
def mtime_tree(directory):
@ -303,28 +299,29 @@ def mtime_tree(directory):
return mtime
def needs_rebuild(image):
def needs_rebuild(image, default_repository=None):
""" check if an image needs rebuilding """
# update base_image layer, if empty
base_img = base_images[image["base"]]
if base_img["layer"] is False:
_, layers = get_time_layers(image["base"])
full_name = full_image_name(image["name"], default_repository)
base_name, _ = expand_base_image(image["base"], full_name)
# update base_image information, if empty
if base_name not in image_info:
_, layers = get_time_layers(base_name)
# store last layer
if layers:
base_img["layer"] = layers[-1]
image_info[base_name] = layers[-1]
else:
sys.exit(f"Missing base image: {image['base']}")
sys.exit(f"Missing base image: {base_name}")
# get image data
full_name = full_image_name(image["name"])
itime, layers = get_time_layers(full_name)
if layers and full_name in base_images: # image is a base image
base_images[full_name]["layer"] = layers[-1]
if layers: # update image information
image_info[full_name] = layers[-1]
# check if base image has changed
if not layers:
return "Image missing in repository"
if base_img["layer"] and base_img["layer"] not in layers:
if image_info[base_name] and image_info[base_name] not in layers:
return "Base image has changed"
# check if build directory has changed, needs full git history
@ -367,11 +364,13 @@ def needs_rebuild(image):
return rebuild_reason if mtime > itime.timestamp() else None
def build(image):
def build(image, default_repository=None):
""" build image """
full_name = full_image_name(image["name"])
full_name = full_image_name(image["name"], default_repository)
_, options = expand_base_image(image["base"], full_name)
options += image["options"]
try:
subprocess.run(["docker", "buildx", "build"] + image["options"] + \
subprocess.run(["docker", "buildx", "build"] + options + \
["--push", "--tag", full_name, image["dir"]],
check=True)
except OSError as err:
@ -380,13 +379,7 @@ def build(image):
sys.exit(err.returncode)
print()
if full_name in base_images: # just modified a base image
_, layers = get_time_layers(full_name)
# store last layer
if layers:
base_images[full_name]["layer"] = layers[-1]
else:
sys.exit(f"{image['name']}: Can't get image layers")
image_info.pop(full_name, None) # remove outdated image information
def fill_login_table():
@ -420,15 +413,13 @@ args = parser.parse_args()
sys.stdout.reconfigure(line_buffering=True)
# DOCKER_REPOSITORY environment
docker_env = {"repository": os.environ.get("DOCKER_REPOSITORY", "")
.lower().rstrip("/")}
if docker_env["repository"]:
docker_repositories = os.environ.get("DOCKER_REPOSITORY", "") \
.strip().lower().split()
for docker_repo in docker_repositories:
try:
docker_env["registry"], *_ = parse_repository(docker_env["repository"])
parse_repository(docker_repo)
except ValueError as err_info:
sys.exit(f"DOCKER_REPOSITORY={docker_env['repository']}: {err_info}")
else:
docker_env["repository"] = docker_env["registry"] = None
sys.exit(f"DOCKER_REPOSITORY: {docker_repo}: {err_info}")
# fill user/password table
docker_login = fill_login_table()
@ -439,22 +430,40 @@ if args.dir:
except OSError as err_info:
sys.exit(f"Can't change directory: {err_info}")
get_images(args.file)
init_base_images()
init_image_info()
# check arguments
all_inames = {img["name"] for img in images}.union(base_images.keys())
all_inames = {img["name"] for img in images} \
.union(img["base"] for img in images)
for iname in args.image:
if iname not in all_inames:
sys.exit(f"Image {iname} not found in '{args.file}' configuration file")
# rebuild images
for img in images:
if xor(args.all, img["name"] in args.image or img["base"] in args.image):
# pragma pylint: disable=invalid-name
reason = False
if xor(args.all, img["name"] in args.image or img["base"] in args.image):
reason = "Rebuild triggered by command line"
else:
reason = needs_rebuild(img)
if "/" in img["name"]:
# full target image name
reason = reason or needs_rebuild(img)
if reason:
print(f"*** {img['name']}\nReason: {reason}\n")
if not args.dry_run:
build(img)
else:
# name-only target image name
if not docker_repositories:
sys.exit("Environment variable DOCKER_REPOSITORY is not defined")
build_repositories = []
for docker_idx, docker_repo in enumerate(docker_repositories):
reason = reason or needs_rebuild(img, docker_repo)
if reason:
build_repositories = docker_repositories[docker_idx:] + \
docker_repositories[:docker_idx]
break
for docker_repo in build_repositories:
print(f"*** {docker_repo}/{img['name']}\nReason: {reason}\n")
if not args.dry_run:
build(img, docker_repo)

View File

@ -76,6 +76,8 @@ image name. Then `docker_build` uses the `DOCKER_REPOSITORY`
environment variable as its initial part. For example, an
DOCKER_REPOSITORY value of "ghcr.io/b-ehlers" plus the image
name of "alpine-1" results in "ghcr.io/b-ehlers/alpine-1".
When `DOCKER_REPOSITORY` contains a list of repositories,
then the name-only targets will be build for all of them.
This method is not applied to the base images, they always
have to contain the complete name.
@ -84,7 +86,7 @@ But there is a workaround.
If the base image name starts with `$DOCKER_REPOSITORY`
or `${DOCKER_REPOSITORY}` the variable DOCKER_REPOSITORY
gets replaced by its value from the environment.
gets replaced by the base part of the target image.
In the Dockerfile the variable must be declared by a
`ARG DOCKER_REPOSITORY` instruction. A Dockerfile would
then start with:

View File

@ -14,6 +14,10 @@ on:
required: false
type: string
# Pause on concurrent builds
concurrency:
group: build-docker-images
jobs:
docker-images:
runs-on: ubuntu-latest
@ -46,7 +50,7 @@ jobs:
- name: Login to GitHub Container Registry
# https://github.com/marketplace/actions/docker-login
# set the condition depending on whether you want to login to ghcr.io.
if: false
if: true
uses: docker/login-action@v2
with:
registry: ghcr.io
@ -58,10 +62,15 @@ jobs:
- name: Build and push images
env:
# DOCKER_REPOSITORY - Repository for name-only images
# DockerHub:
DOCKER_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }}
#DOCKER_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }}
# GitHub Container Registry:
# DOCKER_REPOSITORY: ghcr.io/${{ github.repository_owner }}
#DOCKER_REPOSITORY: ghcr.io/${{ github.repository_owner }}
# Both DockerHub and GitHub Container Registry:
DOCKER_REPOSITORY: >-
${{ secrets.DOCKERHUB_REPOSITORY }}
ghcr.io/${{ github.repository_owner }}
#
# Variables whose name are starting with "DOCKER_LOGIN"
# contain the user/password for a docker registry.