blob: a51203480d7e121fe46a07dcc1119d8a5d2257ae [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Utility to handle distributed docker cache. This is done by keeping the entire image chain of a docker container
on an S3 bucket. This utility allows cache creation and download. After execution, the cache will be in an identical
state as if the container would have been built locally already.
"""
import argparse
import logging
import os
import subprocess
import re
import sys
from platform import machine
from typing import *
import build as build_util
from docker_login import login_dockerhub, logout_dockerhub
from util import retry
DOCKER_CACHE_NUM_RETRIES = 3
DOCKER_CACHE_TIMEOUT_MINS = 45
PARALLEL_BUILDS = 2
DOCKER_CACHE_RETRY_SECONDS = 5
DOCKER_BUILD_NUM_RETRIES = 2
def build_save_containers(platforms, registry, load_cache) -> int:
"""
Entry point to build and upload all built dockerimages in parallel
:param platforms: List of platforms
:param registry: Docker registry name
:param load_cache: Load cache before building
:return: 1 if error occurred, 0 otherwise
"""
from joblib import Parallel, delayed
if len(platforms) == 0:
return 0
platform_results = Parallel(n_jobs=PARALLEL_BUILDS, backend="multiprocessing")(
delayed(_build_save_container)(platform, registry, load_cache)
for platform in platforms)
is_error = False
for platform_result in platform_results:
if platform_result is not None:
logging.error('Failed to generate %s', platform_result)
is_error = True
return 1 if is_error else 0
def _build_save_container(platform, registry, load_cache) -> Optional[str]:
"""
Build image for passed platform and upload the cache to the specified S3 bucket
:param platform: Platform
:param registry: Docker registry name
:param load_cache: Load cache before building
:return: Platform if failed, None otherwise
"""
# docker-compose
if build_util.is_docker_compose(platform):
if "dkr.ecr" in registry:
_ecr_login(registry)
build_util.build_docker(platform=platform, registry=registry,
num_retries=DOCKER_BUILD_NUM_RETRIES, no_cache=False)
docker_compose_service = platform.split(".")[1]
env = os.environ.copy()
env["DOCKER_CACHE_REGISTRY"] = registry
push_cmd = ['docker-compose', '-f', 'docker/docker-compose.yml', 'push', docker_compose_service]
subprocess.check_call(push_cmd, env=env)
return None
docker_tag = build_util.get_docker_tag(platform=platform, registry=registry)
# Preload cache
if load_cache:
load_docker_cache(registry=registry, docker_tag=docker_tag)
# Start building
logging.debug('Building %s as %s', platform, docker_tag)
try:
# Increase the number of retries for building the cache.
image_id = build_util.build_docker(platform=platform, registry=registry,
num_retries=DOCKER_BUILD_NUM_RETRIES, no_cache=False)
logging.info('Built %s as %s', docker_tag, image_id)
# Push cache to registry
_upload_image(registry=registry, docker_tag=docker_tag, image_id=image_id)
return None
except Exception:
logging.exception('Unexpected exception during build of %s', docker_tag)
return platform
# Error handling is done by returning the errorous platform name. This is necessary due to
# Parallel being unable to handle exceptions
ECR_LOGGED_IN = False
def _ecr_login(registry):
"""
Use the AWS CLI to get credentials to login to ECR.
"""
# extract region from registry
global ECR_LOGGED_IN
if ECR_LOGGED_IN:
return
regionMatch = re.match(r'.*?\.dkr\.ecr\.([a-z]+\-[a-z]+\-\d+)\.amazonaws\.com', registry)
assert(regionMatch)
region = regionMatch.group(1)
logging.info("Logging into ECR region %s using aws-cli..", region)
# first check version of aws-cli
aws_cli_output = subprocess.check_output(["aws","--version"])
if aws_cli_output.decode('utf-8').startswith("aws-cli/2"):
os.system("aws ecr get-login-password --region "+region+" | docker login --username AWS --password-stdin "+registry)
else:
os.system("$(aws ecr get-login --region "+region+" --no-include-email)")
ECR_LOGGED_IN = True
def _upload_image(registry, docker_tag, image_id) -> None:
"""
Upload the passed image by id, tag it with docker tag and upload to docker registry.
:param registry: Docker registry name
:param docker_tag: Docker tag
:param image_id: Image id
:return: None
"""
if "dkr.ecr" in registry:
_ecr_login(registry)
# We don't have to retag the image since it is already in the right format
logging.info('Uploading %s (%s) to %s', docker_tag, image_id, registry)
push_cmd = ['docker', 'push', docker_tag]
subprocess.check_call(push_cmd)
@retry(target_exception=subprocess.TimeoutExpired, tries=DOCKER_CACHE_NUM_RETRIES,
delay_s=DOCKER_CACHE_RETRY_SECONDS)
def load_docker_cache(registry, docker_tag) -> None:
"""
Load the precompiled docker cache from the registry
:param registry: Docker registry name
:param docker_tag: Docker tag to load
:return: None
"""
# We don't have to retag the image since it's already in the right format
if not registry:
return
assert docker_tag
if "dkr.ecr" in registry:
_ecr_login(registry)
logging.info('Loading Docker cache for %s from %s', docker_tag, registry)
pull_cmd = ['docker', 'pull', docker_tag]
# Don't throw an error if the image does not exist
subprocess.run(pull_cmd, timeout=DOCKER_CACHE_TIMEOUT_MINS*60)
logging.info('Successfully pulled docker cache')
def delete_local_docker_cache(docker_tag):
"""
Delete the local docker cache for the entire docker image chain
:param docker_tag: Docker tag
:return: None
"""
history_cmd = ['docker', 'history', '-q', docker_tag]
try:
image_ids_b = subprocess.check_output(history_cmd)
image_ids_str = image_ids_b.decode('utf-8').strip()
layer_ids = [id.strip() for id in image_ids_str.split('\n') if id != '<missing>']
delete_cmd = ['docker', 'image', 'rm', '--force']
delete_cmd.extend(layer_ids)
subprocess.check_call(delete_cmd)
except subprocess.CalledProcessError as error:
# Could be caused by the image not being present
logging.debug('Error during local cache deletion %s', error)
def main() -> int:
"""
Utility to create and publish the Docker cache to Docker Hub
:return:
"""
# We need to be in the same directory than the script so the commands in the dockerfiles work as
# expected. But the script can be invoked from a different path
base = os.path.split(os.path.realpath(__file__))[0]
os.chdir(base)
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger('botocore').setLevel(logging.INFO)
logging.getLogger('boto3').setLevel(logging.INFO)
logging.getLogger('urllib3').setLevel(logging.INFO)
logging.getLogger('s3transfer').setLevel(logging.INFO)
def script_name() -> str:
return os.path.split(sys.argv[0])[1]
logging.basicConfig(format='{}: %(asctime)-15s %(message)s'.format(script_name()))
parser = argparse.ArgumentParser(description="Utility for preserving and loading Docker cache", epilog="")
parser.add_argument("--docker-registry",
help="Docker hub registry name",
type=str,
required=True)
args = parser.parse_args()
platforms = build_util.get_platforms(arch=machine())
if "dkr.ecr" in args.docker_registry:
_ecr_login(args.docker_registry)
return build_save_containers(platforms=platforms, registry=args.docker_registry, load_cache=True)
else:
secret_name = os.environ['DOCKERHUB_SECRET_NAME']
endpoint_url = os.environ['DOCKERHUB_SECRET_ENDPOINT_URL']
region_name = os.environ['DOCKERHUB_SECRET_ENDPOINT_REGION']
try:
login_dockerhub(secret_name, endpoint_url, region_name)
return build_save_containers(platforms=platforms, registry=args.docker_registry, load_cache=True)
finally:
logout_dockerhub()
if __name__ == '__main__':
sys.exit(main())