blob: 1d803f48644b522f3ef753e8fe3f5a9c5ae69ce7 [file] [log] [blame]
# Copyright 2015 Google Inc. 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.
"""Rules for manipulation Docker images."""
# Filetype to restrict inputs
tar_filetype = FileType([".tar", ".tar.gz", ".tgz", ".tar.xz"])
deb_filetype = FileType([".deb"])
# Docker files are tarballs, should we allow other extensions than tar?
docker_filetype = tar_filetype
# 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
def _short_path_dirname(path):
"""Returns the directory's name of the short path of an artifact."""
sp = path.short_path
return sp[:sp.rfind("/")]
def _dest_path(f, strip_prefix):
"""Returns the short path of f, stripped of strip_prefix."""
if f.short_path.startswith(strip_prefix):
return f.short_path[len(strip_prefix):]
return f.short_path
def _build_layer(ctx):
"""Build the current layer for appending it the base layer."""
# Compute the relative path
data_path = ctx.attr.data_path
if not data_path:
data_path = _short_path_dirname(ctx.outputs.out)
elif data_path[0] == "/":
data_path = data_path[1:]
else: # relative path
data_path = _short_path_dirname(ctx.outputs.out) + "/" + data_path
layer = ctx.new_file(ctx.label.name + ".layer")
build_layer = ctx.executable._build_layer
args = [
"--output=" + layer.path,
"--directory=" + ctx.attr.directory
]
args += ["--file=%s=%s" % (f.path, _dest_path(f, data_path))
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]
args += ["--link=%s:%s" % (k, ctx.attr.symlinks[k])
for k in ctx.attr.symlinks]
ctx.action(
executable = build_layer,
arguments = args,
inputs = ctx.files.files + ctx.files.tars + ctx.files.debs,
outputs = [layer],
mnemonic="DockerLayer"
)
return layer
def _sha256(ctx, artifact):
"""Create an action to compute the SHA-256 of an artifact."""
out = ctx.new_file(artifact.basename + ".sha256")
ctx.action(
executable = ctx.executable._sha256,
arguments = [artifact.path, out.path],
inputs = [artifact],
outputs = [out],
mnemonic = "SHA256")
return out
def _get_base_artifact(ctx):
if ctx.files.base:
if hasattr(ctx.attr.base, "docker_image"):
return ctx.attr.base.docker_image
if len(ctx.files.base) != 1:
fail("base attribute should be a single tar file.")
return ctx.files.base[0]
def _metadata_action(ctx, layer, name, output):
"""Generate the action to create the JSON metadata for the layer."""
rewrite_tool = ctx.executable._rewrite_tool
env = ctx.attr.env
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),
"--env=%s" % ",".join(["%s=%s" % (k, env[k]) for k in env]),
"--ports=%s" % ",".join(ctx.attr.ports),
"--volumes=%s" % ",".join(ctx.attr.volumes)
]
inputs = [layer, rewrite_tool, name]
base = _get_base_artifact(ctx)
if base:
args += ["--base=%s" % base.path]
inputs += [base]
ctx.action(
executable = rewrite_tool,
arguments = args,
inputs = inputs,
outputs = [output],
mnemonic = "RewriteJSON")
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 _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 _create_image(ctx, layer, name, metadata):
"""Create the new image."""
create_image = ctx.executable._create_image
args = [
"--output=" + ctx.outputs.out.path,
"--metadata=" + metadata.path,
"--layer=" + layer.path,
"--id=@" + name.path,
# We label at push time, so we only put a single name in this file:
# bazel/package:target => {the layer being appended}
# TODO(dmarting): Does the name makes sense? We could use the
# repositoryName/package instead. (why do we need to replace
# slashes?)
"--repository=bazel/" + ctx.label.package.replace("/", "_"),
"--name=" + ctx.label.name
]
inputs = [layer, metadata, name]
# If we have been provided a base image, add it.
base = _get_base_artifact(ctx)
if base:
args += ["--base=%s" % base.path]
inputs += [base]
ctx.action(
executable = create_image,
arguments = args,
inputs = inputs,
use_default_shell_env = True,
outputs = [ctx.outputs.out]
)
def _docker_build_impl(ctx):
"""Implementation for the docker_build rule."""
layer = _build_layer(ctx)
name = _compute_layer_name(ctx, layer)
metadata = _metadata(ctx, layer, name)
_create_image(ctx, layer, name, metadata)
ctx.file_action(
content = "\n".join([
"#!/bin/bash -eu",
"docker load -i " + ctx.outputs.out.short_path
]),
output = ctx.outputs.executable,
executable = True)
return struct(runfiles = ctx.runfiles(files = [ctx.outputs.out]),
docker_image = ctx.outputs.out)
docker_build_ = rule(
implementation = _docker_build_impl,
attrs = {
"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),
"symlinks": attr.string_dict(),
"entrypoint": attr.string_list(),
"cmd": attr.string_list(),
"env": attr.string_dict(),
"ports": attr.string_list(), # Skylark doesn't support int_list...
"volumes": attr.string_list(),
# Implicit dependencies.
"_build_layer": attr.label(
default=Label("//tools/build_defs/docker:build_layer"),
cfg=HOST_CFG,
executable=True,
allow_files=True),
"_create_image": attr.label(
default=Label("//tools/build_defs/docker:create_image"),
cfg=HOST_CFG,
executable=True,
allow_files=True),
"_rewrite_tool": attr.label(
default=Label("//tools/build_defs/docker:rewrite_json"),
cfg=HOST_CFG,
executable=True,
allow_files=True),
"_sha256": attr.label(
default=Label("//tools/build_defs/docker:sha256"),
cfg=HOST_CFG,
executable=True,
allow_files=True)
},
outputs = {
"out": "%{name}.tar",
},
executable = True)
# 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.
#
# 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 this package.
# # 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=[...],
#
# # TODO(mattmoor): NYI
# # https://docs.docker.com/reference/builder/#maintainer
# maintainer="...",
#
# # TODO(mattmoor): NYI
# # 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=[...],
#
# # TODO(mattmoor): NYI
# # https://docs.docker.com/reference/builder/#workdir
# # NOTE: the normal directive affects subsequent RUN, CMD,
# # ENTRYPOINT, ADD, and COPY
# workdir="...",
#
# # https://docs.docker.com/reference/builder/#env
# env = {
# "var1": "val1",
# "var2": "val2",
# ...
# "varN": "valN",
# },
#
# # NOTE: Without a motivating use case, there is little reason to support:
# # https://docs.docker.com/reference/builder/#onbuild
# )
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
...
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 'blaze/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"])
if "entrypoint" in kwargs:
kwargs["entrypoint"] = _validate_command("entrypoint", kwargs["entrypoint"])
docker_build_(**kwargs)