| # 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) |