--- a/.taskcluster.yml
+++ b/.taskcluster.yml
@@ -114,16 +114,12 @@ tasks:
--head-rev='{{revision}}'
--revision-hash='{{revision_hash}}'
artifacts:
'public':
type: 'directory'
path: '/home/worker/artifacts'
expires: '{{#from_now}}364 days{{/from_now}}'
- 'public/docker_image_contexts':
- type: 'directory'
- path: '/home/worker/docker_image_contexts'
- expires: '{{#from_now}}7 days{{/from_now}}'
extra:
treeherder:
symbol: D
--- a/taskcluster/ci/docker-image/image.yml
+++ b/taskcluster/ci/docker-image/image.yml
@@ -5,56 +5,64 @@ task:
deadline:
relative-datestamp: "24 hours"
metadata:
name: 'Docker Image Build: {{image_name}}'
description: 'Build the docker image {{image_name}} for use by dependent tasks'
source: '{{source}}'
owner: mozilla-taskcluster-maintenance@mozilla.com
tags:
- createdForUser: {{owner}}
+ createdForUser: '{{owner}}'
workerType: taskcluster-images
provisionerId: aws-provisioner-v1
schedulerId: task-graph-scheduler
routes:
- - index.docker.images.v1.{{project}}.{{image_name}}.latest
- - index.docker.images.v1.{{project}}.{{image_name}}.pushdate.{{year}}.{{month}}-{{day}}-{{pushtime}}
- - index.docker.images.v1.{{project}}.{{image_name}}.hash.{{context_hash}}
+ # Indexing routes to avoid building the same image twice
+ - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.latest
+ - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.pushdate.{{year}}.{{month}}-{{day}}-{{pushtime}}
+ - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.hash.{{context_hash}}
+ # Treeherder routes
- tc-treeherder.v2.{{project}}.{{head_rev}}.{{pushlog_id}}
- tc-treeherder-stage.v2.{{project}}.{{head_rev}}.{{pushlog_id}}
+ scopes:
+ - secrets:get:project/taskcluster/gecko/hgfingerprint
+ - docker-worker:cache:level-{{level}}-imagebuilder-v1
+
payload:
env:
HASH: '{{context_hash}}'
PROJECT: '{{project}}'
CONTEXT_URL: '{{context_url}}'
- CONTEXT_PATH: '{{context_path}}'
- BASE_REPOSITORY: '{{base_repository}}'
- HEAD_REPOSITORY: '{{head_repository}}'
- HEAD_REV: '{{head_rev}}'
- HEAD_REF: '{{head_ref}}'
+ IMAGE_NAME: '{{image_name}}'
+ GECKO_BASE_REPOSITORY: '{{base_repository}}'
+ GECKO_HEAD_REPOSITORY: '{{head_repository}}'
+ GECKO_HEAD_REV: '{{head_rev}}'
+ HG_STORE_PATH: '/home/worker/checkouts/hg-store'
+ cache:
+ 'level-{{level}}-imagebuilder-v1': '/home/worker/checkouts'
features:
dind: true
chainOfTrust: true
+ taskclusterProxy: true
image: '{{#docker_image}}image_builder{{/docker_image}}'
- command:
- - /bin/bash
- - -c
- - /home/worker/bin/build_image.sh
maxRunTime: 3600
artifacts:
'{{artifact_path}}':
type: 'file'
- path: '/artifacts/image.tar'
+ path: '/home/worker/workspace/artifacts/image.tar.zst'
expires:
relative-datestamp: "1 year"
extra:
+ imageMeta: # Useful when converting back from JSON in action tasks
+ level: '{{level}}'
+ contextHash: '{{context_hash}}'
+ imageName: '{{image_name}}'
treeherderEnv:
- staging
- production
treeherder:
jobKind: other
build:
platform: 'taskcluster-images'
symbol: 'I'
-
--- a/taskcluster/mach_commands.py
+++ b/taskcluster/mach_commands.py
@@ -269,16 +269,22 @@ class TaskClusterImagesProvider(object):
except Exception:
traceback.print_exc()
sys.exit(1)
@Command('taskcluster-build-image', category='ci',
description='Build a Docker image')
@CommandArgument('image_name',
help='Name of the image to build')
- def build_image(self, image_name):
- from taskgraph.docker import build_image
-
+ @CommandArgument('--context-only',
+ help="File name the context tarball should be written to."
+ "with this option it will only build the context.tar.",
+ metavar='context.tar')
+ def build_image(self, image_name, context_only):
+ from taskgraph.docker import build_image, build_context
try:
- build_image(image_name)
+ if context_only is None:
+ build_image(image_name)
+ else:
+ build_context(image_name, context_only)
except Exception:
traceback.print_exc()
sys.exit(1)
--- a/taskcluster/taskgraph/docker.py
+++ b/taskcluster/taskgraph/docker.py
@@ -13,17 +13,17 @@ import tarfile
import tempfile
import urllib2
import which
from taskgraph.util import docker
GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..'))
IMAGE_DIR = os.path.join(GECKO, 'testing', 'docker')
-INDEX_URL = 'https://index.taskcluster.net/v1/task/docker.images.v1.{}.{}.hash.{}'
+INDEX_URL = 'https://index.taskcluster.net/v1/task/' + docker.INDEX_PREFIX + '.{}.{}.hash.{}'
ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
def load_image_by_name(image_name):
context_path = os.path.join(GECKO, 'testing', 'docker', image_name)
context_hash = docker.generate_context_hash(GECKO, context_path, image_name)
image_index_url = INDEX_URL.format('mozilla-central', image_name, context_hash)
@@ -34,19 +34,24 @@ def load_image_by_name(image_name):
def load_image_by_task_id(task_id):
# because we need to read this file twice (and one read is not all the way
# through), it is difficult to stream it. So we download to disk and then
# read it back.
filename = 'temp-docker-image.tar'
- artifact_url = ARTIFACT_URL.format(task_id, 'public/image.tar')
+ artifact_url = ARTIFACT_URL.format(task_id, 'public/image.tar.zst')
print("Downloading", artifact_url)
- subprocess.check_call(['curl', '-#', '-L', '-o', filename, artifact_url])
+ tempfilename = 'temp-docker-image.tar.zst'
+ subprocess.check_call(['curl', '-#', '-L', '-o', tempfilename, artifact_url])
+ print("Decompressing")
+ subprocess.check_call(['zstd', '-d', tempfilename, '-o', filename])
+ print("Deleting temporary file")
+ os.unlink(tempfilename)
print("Determining image name")
tf = tarfile.open(filename)
repositories = json.load(tf.extractfile('repositories'))
name = repositories.keys()[0]
tag = repositories[name].keys()[0]
name = '{}:{}'.format(name, tag)
print("Image name:", name)
@@ -61,16 +66,31 @@ def load_image_by_task_id(task_id):
print("Deleting temporary file")
os.unlink(filename)
print("The requested docker image is now available as", name)
print("Try: docker run -ti --rm {} bash".format(name))
+def build_context(name, outputFile):
+ """Build a context.tar for image with specified name.
+ """
+ if not name:
+ raise ValueError('must provide a Docker image name')
+ if not outputFile:
+ raise ValueError('must provide a outputFile')
+
+ image_dir = os.path.join(IMAGE_DIR, name)
+ if not os.path.isdir(image_dir):
+ raise Exception('image directory does not exist: %s' % image_dir)
+
+ docker.create_context_tar(GECKO, image_dir, outputFile, "")
+
+
def build_image(name):
"""Build a Docker image of specified name.
Output from image building process will be printed to stdout.
"""
if not name:
raise ValueError('must provide a Docker image name')
--- a/taskcluster/taskgraph/task/docker_image.py
+++ b/taskcluster/taskgraph/task/docker_image.py
@@ -2,32 +2,30 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
from __future__ import absolute_import, print_function, unicode_literals
import logging
import json
import os
-import re
import urllib2
from . import base
from taskgraph.util.docker import (
- create_context_tar,
docker_image,
generate_context_hash,
+ INDEX_PREFIX,
)
from taskgraph.util.templates import Templates
logger = logging.getLogger(__name__)
GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
INDEX_URL = 'https://index.taskcluster.net/v1/task/{}'
-INDEX_REGEX = r'index\.(docker\.images\.v1\.(.+)\.(.+)\.hash\.(.+))'
class DockerImageTask(base.Task):
def __init__(self, *args, **kwargs):
self.index_paths = kwargs.pop('index_paths')
super(DockerImageTask, self).__init__(*args, **kwargs)
@@ -49,67 +47,41 @@ class DockerImageTask(base.Task):
'base_repository': params['base_repository'] or params['head_repository'],
'head_repository': params['head_repository'],
'head_ref': params['head_ref'] or params['head_rev'],
'head_rev': params['head_rev'],
'owner': params['owner'],
'level': params['level'],
'source': '{repo}file/{rev}/taskcluster/ci/docker-image/image.yml'
.format(repo=params['head_repository'], rev=params['head_rev']),
+ 'index_image_prefix': INDEX_PREFIX,
+ 'artifact_path': 'public/image.tar.zst',
}
tasks = []
templates = Templates(path)
for image_name in config['images']:
context_path = os.path.join('testing', 'docker', image_name)
+ context_hash = generate_context_hash(GECKO, context_path, image_name)
image_parameters = dict(parameters)
- image_parameters['context_path'] = context_path
- image_parameters['artifact_path'] = 'public/image.tar'
image_parameters['image_name'] = image_name
-
- image_artifact_path = \
- "public/docker_image_contexts/{}/context.tar.gz".format(image_name)
- if os.environ.get('TASK_ID'):
- # We put image context tar balls in a different artifacts folder
- # on the Gecko decision task in order to have longer expiration
- # dates for smaller artifacts.
- destination = os.path.join(
- os.environ['HOME'],
- "docker_image_contexts/{}/context.tar.gz".format(image_name))
- image_parameters['context_url'] = ARTIFACT_URL.format(
- os.environ['TASK_ID'], image_artifact_path)
-
- destination = os.path.abspath(destination)
- if not os.path.exists(os.path.dirname(destination)):
- os.makedirs(os.path.dirname(destination))
-
- context_hash = create_context_tar(GECKO, context_path,
- destination, image_name)
- else:
- # skip context generation since this isn't a decision task
- # TODO: generate context tarballs using subdirectory clones in
- # the image-building task so we don't have to worry about this.
- image_parameters['context_url'] = 'file:///tmp/' + image_artifact_path
- context_hash = generate_context_hash(GECKO, context_path, image_name)
-
image_parameters['context_hash'] = context_hash
image_task = templates.load('image.yml', image_parameters)
-
attributes = {'image_name': image_name}
- # As an optimization, if the context hash exists for mozilla-central, that image
+ # As an optimization, if the context hash exists for a high level, that image
# task ID will be used. The reasoning behind this is that eventually everything ends
- # up on mozilla-central at some point if most tasks use this as a common image
+ # up on level 3 at some point if most tasks use this as a common image
# for a given context hash, a worker within Taskcluster does not need to contain
# the same image per branch.
- index_paths = ['docker.images.v1.{}.{}.hash.{}'.format(
- project, image_name, context_hash)
- for project in ['mozilla-central', params['project']]]
+ index_paths = ['{}.level-{}.{}.hash.{}'.format(
+ INDEX_PREFIX, level, image_name, context_hash)
+ for level in range(int(params['level']), 4)]
tasks.append(cls(kind, 'build-docker-image-' + image_name,
task=image_task['task'], attributes=attributes,
index_paths=index_paths))
return tasks
def get_dependencies(self, taskgraph):
@@ -121,37 +93,34 @@ class DockerImageTask(base.Task):
url = INDEX_URL.format(index_path)
existing_task = json.load(urllib2.urlopen(url))
# Only return the task ID if the artifact exists for the indexed
# task. Otherwise, continue on looking at each of the branches. Method
# continues trying other branches in case mozilla-central has an expired
# artifact, but 'project' might not. Only return no task ID if all
# branches have been tried
request = urllib2.Request(
- ARTIFACT_URL.format(existing_task['taskId'], 'public/image.tar'))
+ ARTIFACT_URL.format(existing_task['taskId'], 'public/image.tar.zst'))
request.get_method = lambda: 'HEAD'
urllib2.urlopen(request)
# HEAD success on the artifact is enough
return True, existing_task['taskId']
except urllib2.HTTPError:
pass
return False, None
@classmethod
def from_json(cls, task_dict):
# Generating index_paths for optimization
- routes = task_dict['task']['routes']
- index_paths = []
- for route in routes:
- index_path_regex = re.compile(INDEX_REGEX)
- result = index_path_regex.search(route)
- if result is None:
- continue
- index_paths.append(result.group(1))
- index_paths.append(result.group(1).replace(result.group(2), 'mozilla-central'))
+ imgMeta = task_dict['task']['extra']['imageMeta']
+ image_name = imgMeta['imageName']
+ context_hash = imgMeta['contextHash']
+ index_paths = ['{}.level-{}.{}.hash.{}'.format(
+ INDEX_PREFIX, level, image_name, context_hash)
+ for level in range(int(imgMeta['level']), 4)]
docker_image_task = cls(kind='docker-image',
label=task_dict['label'],
attributes=task_dict['attributes'],
task=task_dict['task'],
index_paths=index_paths)
return docker_image_task
--- a/taskcluster/taskgraph/test/test_taskgraph.py
+++ b/taskcluster/taskgraph/test/test_taskgraph.py
@@ -6,36 +6,49 @@ from __future__ import absolute_import,
import unittest
from ..graph import Graph
from ..task.docker_image import DockerImageTask
from ..task.transform import TransformTask
from ..taskgraph import TaskGraph
from mozunit import main
+from taskgraph.util.docker import INDEX_PREFIX
class TestTargetTasks(unittest.TestCase):
def test_from_json(self):
+ task = {
+ "routes": [],
+ "extra": {
+ "imageMeta": {
+ "contextHash": "<hash>",
+ "imageName": "<image>",
+ "level": "1"
+ }
+ }
+ }
+ index_paths = ["{}.level-{}.<image>.hash.<hash>".format(INDEX_PREFIX, level)
+ for level in range(1, 4)]
graph = TaskGraph(tasks={
'a': TransformTask(
kind='fancy',
task={
'label': 'a',
'attributes': {},
'dependencies': {},
'when': {},
'task': {'task': 'def'},
}),
'b': DockerImageTask(kind='docker-image',
label='b',
attributes={},
- task={"routes": []},
- index_paths=[]),
+ task=task,
+ index_paths=index_paths),
}, graph=Graph(nodes={'a', 'b'}, edges=set()))
tasks, new_graph = TaskGraph.from_json(graph.to_json())
self.assertEqual(graph.tasks['a'], new_graph.tasks['a'])
self.assertEqual(graph, new_graph)
if __name__ == '__main__':
main()
--- a/taskcluster/taskgraph/transforms/task.py
+++ b/taskcluster/taskgraph/transforms/task.py
@@ -344,17 +344,17 @@ def payload_builder(name):
def build_docker_worker_payload(config, task, task_def):
worker = task['worker']
image = worker['docker-image']
if isinstance(image, dict):
docker_image_task = 'build-docker-image-' + image['in-tree']
task.setdefault('dependencies', {})['docker-image'] = docker_image_task
image = {
- "path": "public/image.tar",
+ "path": "public/image.tar.zst",
"taskId": {"task-reference": "<docker-image>"},
"type": "task-image",
}
features = {}
if worker.get('relengapi-proxy'):
features['relengAPIProxy'] = True
--- a/taskcluster/taskgraph/util/docker.py
+++ b/taskcluster/taskgraph/util/docker.py
@@ -13,16 +13,17 @@ import tempfile
from mozpack.archive import (
create_tar_gz_from_files,
)
GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
DOCKER_ROOT = os.path.join(GECKO, 'testing', 'docker')
+INDEX_PREFIX = 'docker.images.v2'
ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
def docker_image(name, default_version=None):
'''Determine the docker image name, including repository and tag, from an
in-tree docker file.'''
try:
with open(os.path.join(DOCKER_ROOT, name, 'REGISTRY')) as f:
--- a/testing/docker/README.md
+++ b/testing/docker/README.md
@@ -34,17 +34,17 @@ will use that indexed task. This is to
that were built from the same context. In summary, if the image has been built for mozilla-central,
pushes to any branch will use that already built image.
To use within an in-tree task definition, the format is:
```yaml
image:
type: 'task-image'
- path: 'public/image.tar'
+ path: 'public/image.tar.zst'
taskId: '{{#task_id_for_image}}builder{{/task_id_for_image}}'
```
##### Context Directory Hashing
Decision tasks will calculate the sha256 hash of the contents of the image
directory and will determine if the image already exists for a given branch and hash
or if a new image must be built and indexed.
@@ -62,19 +62,19 @@ of the context directory.
This ensures that the hash is consistently calculated and path changes will result
in different hashes being generated.
##### Task Image Index Namespace
Images that are built on push and uploaded as an artifact of a task will be indexed under the
following namespaces.
-* docker.images.v1.{project}.{image_name}.latest
-* docker.images.v1.{project}.{image_name}.pushdate.{year}.{month}-{day}-{pushtime}
-* docker.images.v1.{project}.{image_name}.hash.{context_hash}
+* docker.images.v2.level-{level}.{image_name}.latest
+* docker.images.v2.level-{level}.{image_name}.pushdate.{year}.{month}-{day}-{pushtime}
+* docker.images.v2.level-{level}.{image_name}.hash.{context_hash}
Not only can images be browsed by the pushdate and context hash, but the 'latest' namespace
is meant to view the latest built image. This functions similarly to the 'latest' tag
for docker images that are pushed to a registry.
### Docker Registry Images (prebuilt)
***Deprecation Warning: Use of prebuilt images should only be used for base images (those that other images
--- a/testing/docker/image_builder/Dockerfile
+++ b/testing/docker/image_builder/Dockerfile
@@ -1,34 +1,40 @@
-FROM ubuntu:14.04
+FROM ubuntu:16.04
+
+# %include testing/docker/recipes/tooltool.py
+ADD topsrcdir/testing/docker/recipes/tooltool.py /setup/tooltool.py
+
+# %include testing/docker/recipes/common.sh
+ADD topsrcdir/testing/docker/recipes/common.sh /setup/common.sh
-WORKDIR /home/worker/bin
+# %include testing/docker/recipes/install-mercurial.sh
+ADD topsrcdir/testing/docker/recipes/install-mercurial.sh /setup/install-mercurial.sh
+
+# %include testing/mozharness/external_tools/robustcheckout.py
+ADD topsrcdir/testing/mozharness/external_tools/robustcheckout.py /usr/local/mercurial/robustcheckout.py
+
+# %include testing/docker/recipes/run-task
+ADD topsrcdir/testing/docker/recipes/run-task /usr/local/bin/run-task
-RUN apt-get update && apt-get install -y apt-transport-https
-RUN sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 36A1D7869245C8950F966E92D8576A8BA88D21E9 && \
- sudo sh -c "echo deb https://get.docker.io/ubuntu docker main\
- > /etc/apt/sources.list.d/docker.list"
-RUN apt-get update && apt-get install -y \
- lxc-docker-1.6.1 \
- curl \
- wget \
- git \
- mercurial \
- tar \
- zip \
- unzip \
- vim \
- sudo \
- ca-certificates \
- build-essential
+# Add and run setup script
+ADD build-image.sh /usr/local/bin/build-image.sh
+ADD setup.sh /setup/setup.sh
+RUN bash /setup/setup.sh
+
+# Setup a workspace that won't use AUFS
+VOLUME /home/worker/workspace
-ENV NODE_VERSION v0.12.4
-RUN cd /usr/local/ && \
- curl https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.gz | tar -xz --strip-components 1 && \
- node -v
+# Set variable normally configured at login, by the shells parent process, these
+# are taken from GNU su manual
+ENV HOME /home/worker
+ENV SHELL /bin/bash
+ENV USER worker
+ENV LOGNAME worker
+ENV HOSTNAME taskcluster-worker
+ENV LC_ALL C
-RUN npm install -g taskcluster-vcs@2.3.11
+# Create worker user
+RUN useradd -d /home/worker -s /bin/bash -m worker
-ADD bin /home/worker/bin
-RUN chmod +x /home/worker/bin/*
-
-# Set a default command useful for debugging
-CMD ["/bin/bash", "--login"]
+# Set some sane defaults
+WORKDIR /home/worker/
+CMD build-image.sh
--- a/testing/docker/image_builder/VERSION
+++ b/testing/docker/image_builder/VERSION
@@ -1,1 +1,1 @@
-0.1.5
+1.0.0
deleted file mode 100755
--- a/testing/docker/image_builder/bin/build_image.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash -vex
-
-# Set bash options to exit immediately if a pipeline exists non-zero, expand
-# print a trace of commands, and make output verbose (print shell input as it's
-# read)
-# See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html
-set -x -e -v
-
-# Prefix errors with taskcluster error prefix so that they are parsed by Treeherder
-raise_error() {
- echo
- echo "[taskcluster-image-build:error] $1"
- exit 1
-}
-
-# Ensure that the PROJECT is specified so the image can be indexed
-test -n "$PROJECT" || raise_error "Project must be provided."
-test -n "$HASH" || raise_error "Context Hash must be provided."
-
-mkdir /artifacts
-
-if [ ! -z "$CONTEXT_URL" ]; then
- mkdir /context
- if ! curl -L --retry 5 --connect-timeout 30 --fail "$CONTEXT_URL" | tar -xz --strip-components 1 -C /context; then
- raise_error "Error downloading image context from decision task."
- fi
- CONTEXT_PATH=/context
-else
- tc-vcs checkout /home/worker/workspace/src $BASE_REPOSITORY $HEAD_REPOSITORY $HEAD_REV $HEAD_REF
- CONTEXT_PATH=/home/worker/workspace/src/$CONTEXT_PATH
-fi
-
-test -d $CONTEXT_PATH || raise_error "Context Path $CONTEXT_PATH does not exist."
-test -f "$CONTEXT_PATH/Dockerfile" || raise_error "Dockerfile must be present in $CONTEXT_PATH."
-
-docker build -t $PROJECT:$HASH $CONTEXT_PATH
-docker save $PROJECT:$HASH > /artifacts/image.tar
new file mode 100755
--- /dev/null
+++ b/testing/docker/image_builder/build-image.sh
@@ -0,0 +1,59 @@
+#!/bin/bash -vex
+
+# Set bash options to exit immediately if a pipeline exists non-zero, expand
+# print a trace of commands, and make output verbose (print shell input as it's
+# read)
+# See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html
+set -x -e -v
+
+# Prefix errors with taskcluster error prefix so that they are parsed by Treeherder
+raise_error() {
+ echo
+ echo "[taskcluster-image-build:error] $1"
+ exit 1
+}
+
+# Ensure that the PROJECT is specified so the image can be indexed
+test -n "$PROJECT" || raise_error "PROJECT must be provided."
+test -n "$HASH" || raise_error "Context HASH must be provided."
+test -n "$IMAGE_NAME" || raise_error "IMAGE_NAME must be provided."
+
+# Create artifact folder
+mkdir -p /home/worker/workspace/artifacts
+
+# Construct a CONTEXT_FILE
+CONTEXT_FILE=/home/worker/workspace/context.tar
+
+# Run ./mach taskcluster-build-image with --context-only to build context
+run-task \
+ --chown-recursive "/home/worker/workspace" \
+ --vcs-checkout "/home/worker/checkouts/gecko" \
+ -- \
+ /home/worker/checkouts/gecko/mach taskcluster-build-image \
+ --context-only "$CONTEXT_FILE" \
+ "$IMAGE_NAME"
+test -f "$CONTEXT_FILE" || raise_error "Context file wasn't created"
+
+# Post context tar-ball to docker daemon
+# This interacts directly with the docker remote API, see:
+# https://docs.docker.com/engine/reference/api/docker_remote_api_v1.18/
+curl -s \
+ -X POST \
+ --header 'Content-Type: application/tar' \
+ --data-binary "@$CONTEXT_FILE" \
+ --unix-socket /var/run/docker.sock "http:/build?t=$IMAGE_NAME:$HASH" \
+ | tee /tmp/docker-build.log \
+ | jq -r '.status + .progress, .stream[:-1], .error | select(. != null)'
+
+# Exit non-zero if there is error entries in the log
+if cat /tmp/docker-build.log | jq -se 'add | .error' > /dev/null; then
+ raise_error "Image build failed: `cat /tmp/docker-build.log | jq -rse 'add | .error'`";
+fi
+
+# Get image from docker daemon
+# This interacts directly with the docker remote API, see:
+# https://docs.docker.com/engine/reference/api/docker_remote_api_v1.18/
+curl -s \
+ -X GET \
+ --unix-socket /var/run/docker.sock "http:/images/$IMAGE_NAME:$HASH/get" \
+ | zstd -3 -c -o /home/worker/workspace/artifacts/image.tar.zst
new file mode 100644
--- /dev/null
+++ b/testing/docker/image_builder/setup.sh
@@ -0,0 +1,53 @@
+#!/bin/bash -vex
+set -v -e -x
+
+export DEBIAN_FRONTEND=noninteractive
+
+# Update apt-get lists
+apt-get update -y
+
+# Install dependencies
+apt-get install -y \
+ curl \
+ tar \
+ jq \
+ python \
+ build-essential # Only needed for zstd installation, will be removed later
+
+# Install mercurial
+. /setup/common.sh
+. /setup/install-mercurial.sh
+
+# Install build-image.sh script
+chmod +x /usr/local/bin/build-image.sh
+chmod +x /usr/local/bin/run-task
+
+# Create workspace
+mkdir -p /home/worker/workspace
+
+# Install zstd 1.1.1
+cd /setup
+tooltool_fetch <<EOF
+[
+ {
+ "size": 734872,
+ "visibility": "public",
+ "digest": "a8817e74254f21ee5b76a21691e009ede2cdc70a78facfa453902df3e710e90e78d67f2229956d835960fd1085c33312ff273771b75f9322117d85eb35d8e695",
+ "algorithm": "sha512",
+ "filename": "zstd.tar.gz"
+ }
+]
+EOF
+cd -
+tar -xvf /setup/zstd.tar.gz -C /setup
+make -C /setup/zstd-1.1.1/programs install
+rm -rf /tmp/zstd-1.1.1/ /tmp/zstd.tar.gz
+apt-get purge -y build-essential
+
+# Purge apt-get caches to minimize image size
+apt-get auto-remove -y
+apt-get clean -y
+rm -rf /var/lib/apt/lists/
+
+# Remove this script
+rm -rf /setup/