blob: 9399b0d9e6caf1808751f5b181906d8e8bb3d0e6 [file] [log] [blame]
# Copyright 2017 The Bazel Authors. All rights reserved.
#
# Licensed 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.
"""Rule for building a Docker image."""
load(
":filetype.bzl",
deb_filetype = "deb",
docker_filetype = "docker",
tar_filetype = "tar",
)
load(
"//tools/build_defs/hash:hash.bzl",
_hash_tools = "tools",
_sha256 = "sha256",
)
load(":label.bzl", _string_to_label = "string_to_label")
load(
":layers.bzl",
_assemble_image = "assemble",
_get_layers = "get_from_target",
_incr_load = "incremental_load",
_layer_tools = "tools",
)
load(":list.bzl", "reverse")
load(
":path.bzl",
"dirname",
"strip_prefix",
_canonicalize_path = "canonicalize",
_join_path = "join",
)
load(":serialize.bzl", _serialize_dict = "dict_to_associative_list")
def _build_layer(ctx):
"""Build the current layer for appending it the base layer."""
layer = ctx.new_file(ctx.label.name + ".layer")
build_layer = ctx.executable.build_layer
args = [
"--output=" + layer.path,
"--directory=" + ctx.attr.directory,
"--mode=" + ctx.attr.mode,
]
if ctx.attr.data_path:
# If data_prefix is specified, then add files relative to that.
data_path = _join_path(
dirname(ctx.outputs.out.short_path),
_canonicalize_path(ctx.attr.data_path),
)
args += [
"--file=%s=%s" % (f.path, strip_prefix(f.short_path, data_path))
for f in ctx.files.files
]
else:
# Otherwise, files are added without a directory prefix at all.
args += [
"--file=%s=%s" % (f.path, f.basename)
for f in ctx.files.files
]
args += ["--tar=" + f.path for f in ctx.files.tars]
args += ["--deb=" + f.path for f in ctx.files.debs if f.path.endswith(".deb")]
args += [
"--link=%s:%s" % (k, ctx.attr.symlinks[k])
for k in ctx.attr.symlinks
]
arg_file = ctx.new_file(ctx.label.name + ".layer.args")
ctx.file_action(arg_file, "\n".join(args))
ctx.action(
executable = build_layer,
arguments = ["--flagfile=" + arg_file.path],
inputs = ctx.files.files + ctx.files.tars + ctx.files.debs + [arg_file],
outputs = [layer],
use_default_shell_env = True,
mnemonic = "DockerLayer",
)
return layer
# TODO(mattmoor): In a future change, we should establish the invariant that
# base must expose "docker_layers", possibly by hoisting a "docker_load" rule
# from a tarball "base".
def _get_base_artifact(ctx):
if ctx.files.base:
if hasattr(ctx.attr.base, "docker_layers"):
# The base is the first layer in docker_layers if provided.
return _get_layers(ctx, ctx.attr.base)[0]["layer"]
if len(ctx.files.base) != 1:
fail("base attribute should be a single tar file.")
return ctx.files.base[0]
def _image_config(ctx, layer_names):
"""Create the configuration for a new docker image."""
config = ctx.new_file(ctx.label.name + ".config")
label_file_dict = _string_to_label(
ctx.files.label_files,
ctx.attr.label_file_strings,
)
labels = dict()
for l in ctx.attr.labels:
fname = ctx.attr.labels[l]
if fname[0] == "@":
labels[l] = "@" + label_file_dict[fname[1:]].path
else:
labels[l] = fname
args = [
"--output=%s" % config.path,
"--entrypoint=%s" % ",".join(ctx.attr.entrypoint),
"--command=%s" % ",".join(ctx.attr.cmd),
"--labels=%s" % _serialize_dict(labels),
"--env=%s" % _serialize_dict(ctx.attr.env),
"--ports=%s" % ",".join(ctx.attr.ports),
"--volumes=%s" % ",".join(ctx.attr.volumes),
]
if ctx.attr.user:
args += ["--user=" + ctx.attr.user]
if ctx.attr.workdir:
args += ["--workdir=" + ctx.attr.workdir]
inputs = layer_names
args += ["--layer=@" + l.path for l in layer_names]
if ctx.attr.label_files:
inputs += ctx.files.label_files
base = _get_base_artifact(ctx)
if base:
args += ["--base=%s" % base.path]
inputs += [base]
ctx.action(
executable = ctx.executable.create_image_config,
arguments = args,
inputs = inputs,
outputs = [config],
use_default_shell_env = True,
mnemonic = "ImageConfig",
)
return config
def _metadata_action(ctx, layer, name, output):
"""Generate the action to create the JSON metadata for the layer."""
rewrite_tool = ctx.executable.rewrite_tool
label_file_dict = _string_to_label(
ctx.files.label_files,
ctx.attr.label_file_strings,
)
labels = dict()
for l in ctx.attr.labels:
fname = ctx.attr.labels[l]
if fname[0] == "@":
labels[l] = "@" + label_file_dict[fname[1:]].path
else:
labels[l] = fname
args = [
"--output=%s" % output.path,
"--layer=%s" % layer.path,
"--name=@%s" % name.path,
"--entrypoint=%s" % ",".join(ctx.attr.entrypoint),
"--command=%s" % ",".join(ctx.attr.cmd),
"--labels=%s" % _serialize_dict(labels),
"--env=%s" % _serialize_dict(ctx.attr.env),
"--ports=%s" % ",".join(ctx.attr.ports),
"--volumes=%s" % ",".join(ctx.attr.volumes),
]
if ctx.attr.workdir:
args += ["--workdir=" + ctx.attr.workdir]
inputs = [layer, rewrite_tool, name]
if ctx.attr.label_files:
inputs += ctx.files.label_files
# TODO(mattmoor): Does this properly handle naked tarballs?
base = _get_base_artifact(ctx)
if base:
args += ["--base=%s" % base.path]
inputs += [base]
if ctx.attr.user:
args += ["--user=" + ctx.attr.user]
ctx.action(
executable = rewrite_tool,
arguments = args,
inputs = inputs,
outputs = [output],
use_default_shell_env = True,
mnemonic = "RewriteJSON",
)
def _metadata(ctx, layer, name):
"""Create the metadata for the new docker image."""
metadata = ctx.new_file(ctx.label.name + ".metadata")
_metadata_action(ctx, layer, name, metadata)
return metadata
def _compute_layer_name(ctx, layer):
"""Compute the layer's name.
This function synthesize a version of its metadata where in place
of its final name, we use the SHA256 of the layer blob.
This makes the name of the layer a function of:
- Its layer's SHA256
- Its metadata
- Its parent's name.
Assuming the parent's name is derived by this same rigor, then
a simple induction proves the content addressability.
Args:
ctx: Rule context.
layer: The layer's artifact for which to compute the name.
Returns:
The artifact that will contains the name for the layer.
"""
metadata = ctx.new_file(ctx.label.name + ".metadata-name")
layer_sha = _sha256(ctx, layer)
_metadata_action(ctx, layer, layer_sha, metadata)
return _sha256(ctx, metadata)
def _repository_name(ctx):
"""Compute the repository name for the current rule."""
if ctx.attr.legacy_repository_naming:
# Legacy behavior, off by default.
return _join_path(ctx.attr.repository, ctx.label.package.replace("/", "_"))
# Newer Docker clients support multi-level names, which are a part of
# the v2 registry specification.
return _join_path(ctx.attr.repository, ctx.label.package)
def _create_image(ctx, layers, identifier, config, name, metadata, tags):
"""Create the new image."""
args = [
"--output=" + ctx.outputs.layer.path,
"--id=@" + identifier.path,
"--config=" + config.path,
] + ["--tag=" + tag for tag in tags]
args += ["--layer=@%s=%s" % (l["name"].path, l["layer"].path) for l in layers]
inputs = [identifier, config] + [l["name"] for l in layers] + [l["layer"] for l in layers]
if name:
args += ["--legacy_id=@" + name.path]
inputs += [name]
if metadata:
args += ["--metadata=" + metadata.path]
inputs += [metadata]
# If we have been provided a base image, add it.
if ctx.attr.base and not hasattr(ctx.attr.base, "docker_layers"):
legacy_base = _get_base_artifact(ctx)
if legacy_base:
args += ["--legacy_base=%s" % legacy_base.path]
inputs += [legacy_base]
# TODO(mattmoor): Does this properly handle naked tarballs? (excl. above)
base = _get_base_artifact(ctx)
if base:
args += ["--base=%s" % base.path]
inputs += [base]
ctx.action(
executable = ctx.executable.create_image,
arguments = args,
inputs = inputs,
outputs = [ctx.outputs.layer],
mnemonic = "CreateImage",
)
def _docker_build_impl(ctx):
"""Implementation for the docker_build rule."""
layer = _build_layer(ctx)
layer_sha = _sha256(ctx, layer)
config = _image_config(ctx, [layer_sha])
identifier = _sha256(ctx, config)
name = _compute_layer_name(ctx, layer)
metadata = _metadata(ctx, layer, name)
# Construct a temporary name based on the build target.
tags = [_repository_name(ctx) + ":" + ctx.label.name]
# creating a partial image so only pass the layers that belong to it
image_layer = {"layer": layer, "name": layer_sha}
_create_image(ctx, [image_layer], identifier, config, name, metadata, tags)
# Compute the layers transitive provider.
# This must includes all layers of the image, including:
# - The layer introduced by this rule.
# - The layers transitively introduced by docker_build deps.
# - Layers introduced by a static tarball base.
# This is because downstream tooling should just be able to depend on
# the availability and completeness of this field.
layers = [
{"layer": ctx.outputs.layer, "id": identifier, "name": name},
] + _get_layers(ctx, ctx.attr.base)
# Generate the incremental load statement
_incr_load(
ctx,
layers,
{
tag_name: {"name": name, "id": identifier}
for tag_name in tags
},
ctx.outputs.executable,
)
_assemble_image(
ctx,
reverse(layers),
{tag_name: name for tag_name in tags},
ctx.outputs.out,
)
runfiles = ctx.runfiles(
files = ([l["name"] for l in layers] +
[l["id"] for l in layers] +
[l["layer"] for l in layers]),
)
return struct(
runfiles = runfiles,
files = depset([ctx.outputs.layer]),
docker_layers = layers,
)
docker_build_ = rule(
implementation = _docker_build_impl,
attrs = dict({
"base": attr.label(allow_files = docker_filetype),
"data_path": attr.string(),
"directory": attr.string(default = "/"),
"tars": attr.label_list(allow_files = tar_filetype),
"debs": attr.label_list(allow_files = deb_filetype),
"files": attr.label_list(allow_files = True),
"legacy_repository_naming": attr.bool(default = False),
"mode": attr.string(default = "0555"),
"symlinks": attr.string_dict(),
"entrypoint": attr.string_list(),
"cmd": attr.string_list(),
"user": attr.string(),
"env": attr.string_dict(),
"labels": attr.string_dict(),
"ports": attr.string_list(), # Skylark doesn't support int_list...
"volumes": attr.string_list(),
"workdir": attr.string(),
"repository": attr.string(default = "bazel"),
# Implicit dependencies.
"label_files": attr.label_list(
allow_files = True,
),
"label_file_strings": attr.string_list(),
"build_layer": attr.label(
default = Label("//tools/build_defs/pkg:build_tar"),
cfg = "host",
executable = True,
allow_files = True,
),
"create_image": attr.label(
default = Label("//tools/build_defs/docker:create_image"),
cfg = "host",
executable = True,
allow_files = True,
),
"rewrite_tool": attr.label(
default = Label("//tools/build_defs/docker:rewrite_json"),
cfg = "host",
executable = True,
allow_files = True,
),
"create_image_config": attr.label(
default = Label("//tools/build_defs/docker:create_image_config"),
cfg = "host",
executable = True,
allow_files = True,
),
}.items() + _hash_tools.items() + _layer_tools.items()),
outputs = {
"out": "%{name}.tar",
"layer": "%{name}-layer.tar",
},
executable = True,
)
# This validates the two forms of value accepted by
# ENTRYPOINT and CMD, turning them into a canonical
# python list form.
#
# The Dockerfile construct:
# ENTRYPOINT "/foo"
# Results in:
# "Entrypoint": [
# "/bin/sh",
# "-c",
# "\"/foo\""
# ],
# Whereas:
# ENTRYPOINT ["/foo", "a"]
# Results in:
# "Entrypoint": [
# "/foo",
# "a"
# ],
# NOTE: prefacing a command with 'exec' just ends up with the former
def _validate_command(name, argument):
if type(argument) == "string":
return ["/bin/sh", "-c", argument]
elif type(argument) == "list":
return argument
elif argument:
fail("The %s attribute must be a string or list, if specified." % name)
else:
return None
# Produces a new docker image tarball compatible with 'docker load', which
# is a single additional layer atop 'base'. The goal is to have relatively
# complete support for building docker image, from the Dockerfile spec.
#
# For more information see the 'Config' section of the image specification:
# https://github.com/opencontainers/image-spec/blob/v0.2.0/serialization.md
#
# Only 'name' is required. All other fields have sane defaults.
#
# docker_build(
# name="...",
# visibility="...",
#
# # The base layers on top of which to overlay this layer,
# # equivalent to FROM.
# base="//another/build:rule",
#
# # The base directory of the files, defaulted to
# # the package of the input.
# # All files structure relatively to that path will be preserved.
# # A leading '/' mean the workspace root and this path is relative
# # to the current package by default.
# data_path="...",
#
# # The directory in which to expand the specified files,
# # defaulting to '/'.
# # Only makes sense accompanying one of files/tars/debs.
# directory="...",
#
# # The set of archives to expand, or packages to install
# # within the chroot of this layer
# files=[...],
# tars=[...],
# debs=[...],
#
# # The set of symlinks to create within a given layer.
# symlinks = {
# "/path/to/link": "/path/to/target",
# ...
# },
#
# # https://docs.docker.com/reference/builder/#entrypoint
# entrypoint="...", or
# entrypoint=[...], -- exec form
#
# # https://docs.docker.com/reference/builder/#cmd
# cmd="...", or
# cmd=[...], -- exec form
#
# # https://docs.docker.com/reference/builder/#expose
# ports=[...],
#
# # https://docs.docker.com/reference/builder/#user
# # NOTE: the normal directive affects subsequent RUN, CMD,
# # and ENTRYPOINT
# user="...",
#
# # https://docs.docker.com/reference/builder/#volume
# volumes=[...],
#
# # https://docs.docker.com/reference/builder/#workdir
# # NOTE: the normal directive affects subsequent RUN, CMD,
# # ENTRYPOINT, ADD, and COPY, but this attribute only affects
# # the entry point.
# workdir="...",
#
# # https://docs.docker.com/reference/builder/#env
# env = {
# "var1": "val1",
# "var2": "val2",
# ...
# "varN": "valN",
# },
# )
def docker_build(**kwargs):
"""Package a docker image.
This rule generates a sequence of genrules the last of which is named 'name',
so the dependency graph works out properly. The output of this rule is a
tarball compatible with 'docker save/load' with the structure:
{layer-name}:
layer.tar
VERSION
json
{image-config-sha256}.json
...
manifest.json
repositories
top # an implementation detail of our rules, not consumed by Docker.
This rule appends a single new layer to the tarball of this form provided
via the 'base' parameter.
The images produced by this rule are always named 'bazel/tmp:latest' when
loaded (an internal detail). The expectation is that the images produced
by these rules will be uploaded using the 'docker_push' rule below.
Args:
**kwargs: See above.
"""
if "cmd" in kwargs:
kwargs["cmd"] = _validate_command("cmd", kwargs["cmd"])
for reserved in ["label_files", "label_file_strings"]:
if reserved in kwargs:
fail("reserved for internal use by docker_build macro", attr = reserved)
if "labels" in kwargs:
files = sorted(depset([v[1:] for v in kwargs["labels"].values() if v[0] == "@"]))
kwargs["label_files"] = files
kwargs["label_file_strings"] = files
if "entrypoint" in kwargs:
kwargs["entrypoint"] = _validate_command("entrypoint", kwargs["entrypoint"])
docker_build_(**kwargs)