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

"""Rules for generating toolchain configs for a Docker container.

Exposes the docker_autoconfigure rule that does the following:
- Receive a base container as main input. Base container could have a desired
  set of toolchains (i.e., a C compiler, C libraries, java, python, zip, and
  other tools) installed.
- Optionally, install more debian packages in the base container (any packages
  that might be needed by Bazel not installed in your container).
- Optionally, install a given Bazel version on the container.
- Extend the container to install sources for a project.
- Run a bazel command to build one or more targets from
  remote repositories, inside the container.
- Copy toolchain configs (outputs of remote repo targets) produced
  from the execution of Bazel inside the container to the host.
- Optionally copy outputs to a folder defined via build variables.

Example:

  docker_toolchain_autoconfig(
      name = "my-autoconfig-rule",
      base = "@my_image//image:image.tar",
      bazel_version = "0.10.0",
      config_repos = ["local_config_cc", "<some_other_skylark_repo>"],
      git_repo = "https://github.com/some_git_repo",
      env = {
          ... Dictionary of env variables to configure Bazel properly
              for the container, see environments.bzl for examples.
      },
      packages = [
          "package_1",
          "package_2=version",
      ],
      # Any additional debian repos and keys needed to install packages above,
      # not needed if no packages are installed.
      additional_repos = [
          "deb http://deb.debian.org/debian jessie-backports main",
      ],
      keys = [
          "@some_gpg//file",
      ],
  )

Add to your WORKSPACE file the following:

  http_archive(
    name = "bazel_toolchains",
    urls = [
      "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/<latest_release>.tar.gz",
      "https://github.com/bazelbuild/bazel-toolchains/archive/<latest_release>.tar.gz",
    ],
    strip_prefix = "bazel-toolchains-<latest_commit>",
    sha256 = "<sha256>",
  )

  load(
    "@bazel_toolchains//repositories:repositories.bzl",
    bazel_toolchains_repositories = "repositories",
  )

  bazel_toolchains_repositories()

  load(
      "@io_bazel_rules_docker//container:container.bzl",
      container_repositories = "repositories",
      "container_pull",
  )

  container_repositories()

  # Pulls the my_image used as base for example above
  container_pull(
      name = "my_image",
      digest = "sha256:<sha256>",
      registry = "<registry>",
      repository = "<repo>",
  )

  # GPG file used by example above
  http_file(
    name = "some_gpg",
    sha256 = "<sha256>",
    url = "<URL>",
  )

For values of <latest_release> and other placeholders above, please see
the WORKSPACE file in this repo.

To use the rule run:

  bazel run --define=DOCKER_AUTOCONF_OUTPUT=<some_output_dir> //<location_of_rule>:my-autoconfig-rule

Once rule finishes running the file <some_output_dir>/my-autoconfig-rule.tar
will be created with all toolchain configs generated by
"local_config_cc" and "<some_other_skylark_repo>". If no value for
DOCKER_AUTOCONF_OUTPUT is passed, the resulting tar file is left in /tmp.

Known issues:

 - 'name' of rule must conform to docker image naming standards
 - Rule cannot be placed in the BUILD file at the root of a project
"""

load(
    "@io_bazel_rules_docker//container:container.bzl",
    _container = "container",
)
load("//container/rules:docker_toolchains.bzl", "toolchain_container")

# External folder is set to be deprecated, lets keep it here for easy
# refactoring
# https://github.com/bazelbuild/bazel/issues/1262
_EXTERNAL_FOLDER_PREFIX = "external/"

# Name of the current workspace
_WORKSPACE_NAME = "bazel_toolchains"

_WORKSPACE_PREFIX = "@" + _WORKSPACE_NAME + "//"

# Default cc project to use if no git_repo is provided.
_DEFAULT_AUTOCONFIG_PROJECT_PKG_TAR = _WORKSPACE_PREFIX + "rules:cc-sample-project-tar"

# Build variable to define the location of the output tar produced by
# docker_toolchain_autoconfig
_DOCKER_AUTOCONF_OUTPUT = "DOCKER_AUTOCONF_OUTPUT"

# Filetype to restrict inputs
tar_filetype = [
    ".tar",
    ".tar.xz",
]

def _docker_toolchain_autoconfig_impl(ctx):
    """Implementation for the docker_toolchain_autoconfig rule.

    Args:
      ctx: context. See docker_toolchain_autoconfig below for details
          of what this ctx must include
    Returns:
      null
    """
    bazel_config_dir = "/bazel-config"
    project_repo_dir = "project_src"

    # Command to retrieve the project from github if requested.
    clone_repo_cmd = "cd ."
    if ctx.attr.git_repo:
        clone_repo_cmd = ("cd " + bazel_config_dir + " && git clone " +
                          ctx.attr.git_repo + " " + project_repo_dir)

    # Command to install custom Bazel version (if requested)
    install_bazel_cmd = "cd ."
    if ctx.attr.use_bazel_head:
        # If use_bazel_head was requested, we clone the source code from github and compile
        # it using the release version with "bazel build //src:bazel".
        install_bazel_cmd = "/install_bazel_head.sh"
    elif ctx.attr.bazel_version:
        # If a specific Bazel and Bazel RC version is specified, install that version.
        bazel_url = "https://releases.bazel.build/" + ctx.attr.bazel_version
        if ctx.attr.bazel_rc_version:
            bazel_url += ("/rc" + ctx.attr.bazel_rc_version +
                          "/bazel-" + ctx.attr.bazel_version + "rc" +
                          ctx.attr.bazel_rc_version)
        else:
            bazel_url += "/release/bazel-" + ctx.attr.bazel_version
        bazel_url += "-installer-linux-x86_64.sh"
        install_bazel_cmd = "/install_bazel_version.sh " + bazel_url

    # Command to recursively convert soft links to hard links in the config_repos
    deref_symlinks_cmd = []
    for config_repo in ctx.attr.config_repos:
        symlinks_cmd = ("find $(bazel info output_base)/" +
                        _EXTERNAL_FOLDER_PREFIX + config_repo +
                        " -type l -exec bash -c 'ln -f \"$(readlink -m \"$0\")\" \"$0\"' {} \;")
        deref_symlinks_cmd.append(symlinks_cmd)
    deref_symlinks_cmd = " && ".join(deref_symlinks_cmd)

    # Command to copy produced toolchain configs to outside of
    # the containter (to the mount_point).
    copy_cmd = []
    for config_repo in ctx.attr.config_repos:
        src_dir = "$(bazel info output_base)/" + _EXTERNAL_FOLDER_PREFIX + config_repo
        copy_cmd.append("cp -dr " + src_dir + " " + bazel_config_dir + "/")

        # We need to change the owner of the files we copied, so that they can
        # be manipulated from outside the container.
        copy_cmd.append("chown -R $USER_ID " + bazel_config_dir + "/" + config_repo)
    output_copy_cmd = " && ".join(copy_cmd)

    # Command to run autoconfigure targets.
    bazel_cmd = "cd " + bazel_config_dir + "/" + project_repo_dir
    if ctx.attr.use_default_project:
        bazel_cmd += " && touch WORKSPACE && mv BUILD.sample BUILD"

    # For each config repo we run the target @<config_repo>//...
    bazel_targets = "@" + "//... @".join(ctx.attr.config_repos) + "//..."
    bazel_flags = ""
    if not ctx.attr.incompatible_changes_off:
        bazel_flags += " --all_incompatible_changes"
    bazel_cmd += " && bazel build " + bazel_flags + " " + bazel_targets

    # Command to run to clean up after autoconfiguration.
    # we start with "cd ." to make sure in case of failure everything after the
    # ";" will be executed
    clean_cmd = "cd . ; bazel clean"
    if ctx.attr.use_default_project:
        clean_cmd += " && rm WORKSPACE"
    if ctx.attr.git_repo:
        clean_cmd += " && cd " + bazel_config_dir + " && rm -drf " + project_repo_dir

    # Full command to use for docker container
    # TODO(xingao): Make sure the command exits with error right away if a sub
    # command fails.
    docker_cmd = [
        "/bin/sh",
        "-c",
        " && ".join([
            "set -ex",
            ctx.attr.setup_cmd,
            install_bazel_cmd,
            "echo === Cloning project repo ===",
            clone_repo_cmd,
            "echo === Running Bazel autoconfigure command ===",
            bazel_cmd,
            "echo === Copying outputs ===",
            deref_symlinks_cmd,
            output_copy_cmd,
            "echo === Cleaning up ===",
            clean_cmd,
        ]),
    ]

    # Expand contents of repo_pkg_tar
    # (and remove them after we're done running the docker command).
    # A dummy command that does nothing in case git_repo was used
    expand_repo_cmd = "cd ."
    remove_repo_cmd = "cd ."
    if ctx.attr.repo_pkg_tar:
        repo_pkg_tar = str(ctx.attr.repo_pkg_tar.label.name)
        package_name = str(ctx.attr.repo_pkg_tar.label.package)

        # Expand the tar file pointed by repo_pkg_tar
        expand_repo_cmd = ("mkdir ./%s ; tar -xf %s/%s.tar -C ./%s" %
                           (project_repo_dir, package_name, repo_pkg_tar, project_repo_dir))
        remove_repo_cmd = ("rm -drf ./%s" % project_repo_dir)

    result = _container.image.implementation(ctx, cmd = docker_cmd, output_executable = ctx.outputs.load_image)

    # By default we copy the produced tar file to /tmp/
    output_location = "/tmp/" + ctx.attr.name + ".tar"
    if _DOCKER_AUTOCONF_OUTPUT in ctx.var:
        output_location = ctx.var[_DOCKER_AUTOCONF_OUTPUT] + "/" + ctx.attr.name + ".tar"

    # Create the script to load image and run it
    ctx.actions.expand_template(
        template = ctx.files.run_tpl[0],
        substitutions = {
            "%{EXPAND_REPO_CMD}": expand_repo_cmd,
            "%{LOAD_IMAGE_SH}": ctx.outputs.load_image.short_path,
            "%{IMAGE_NAME}": "bazel/" + ctx.label.package + ":" + ctx.label.name,
            "%{RM_REPO_CMD}": remove_repo_cmd,
            "%{CONFIG_REPOS}": " ".join(ctx.attr.config_repos),
            "%{OUTPUT}": output_location,
        },
        output = ctx.outputs.executable,
        is_executable = True,
    )

    # add to the runfiles the script to load image and (if needed) the repo_pkg_tar file
    if hasattr(result.providers[1], "runfiles"):
        result_runfiles = result.providers[1].runfiles
    else:
        result_runfiles = result.providers[1].default_runfiles
    runfiles_list = result_runfiles.files.to_list() + [ctx.outputs.load_image]
    if ctx.attr.repo_pkg_tar:
        runfiles_list += ctx.files.repo_pkg_tar

    runfiles = ctx.runfiles(files = runfiles_list)

    return struct(
        runfiles = runfiles,
        files = result.providers[1].files,
        container_parts = result.container_parts,
    )

docker_toolchain_autoconfig_ = rule(
    attrs = _container.image.attrs + {
        "config_repos": attr.string_list(default = ["local_config_cc"]),
        "use_default_project": attr.bool(default = False),
        "git_repo": attr.string(),
        "repo_pkg_tar": attr.label(allow_files = tar_filetype),
        "bazel_version": attr.string(),
        "bazel_rc_version": attr.string(),
        "use_bazel_head": attr.bool(default = False),
        "run_tpl": attr.label(allow_files = True),
        "setup_cmd": attr.string(default = "cd ."),
        "packages": attr.string_list(),
        "additional_repos": attr.string_list(),
        "keys": attr.string_list(),
        "incompatible_changes_off": attr.bool(default = False),
        "test": attr.bool(default = True),
    },
    executable = True,
    outputs = _container.image.outputs + {
        "load_image": "%{name}_load_image.sh",
    },
    implementation = _docker_toolchain_autoconfig_impl,
)

# Attributes below are expected in ctx, but should not be provided
# in the BUILD file.
reserved_attrs = [
    "use_default_project",
    "files",
    "debs",
    "repo_pkg_tar",
    "run_tpl",
    # all the attrs from docker_build we dont want users to set
    "directory",
    "tars",
    "legacy_repository_naming",
    "legacy_run_behavior",
    "docker_run_flags",
    "mode",
    "symlinks",
    "entrypoint",
    "cmd",
    "user",
    "labels",
    "ports",
    "volumes",
    "workdir",
    "repository",
    "label_files",
    "label_file_strings",
    "empty_files",
    "build_layer",
    "create_image_config",
    "sha256",
    "incremental_load_template",
    "join_layers",
    "extract_config",
]

# Attrs expected in the BUILD rule
required_attrs = [
    "base",
]

def docker_toolchain_autoconfig(**kwargs):
    """Generate toolchain configs for a docker container.

    This rule produces a tar file with toolchain configs produced from the
    execution of targets in skylark remote repositories. Typically, this rule is
    used to produce toolchain configs for the local_config_cc repository.
    This repo (as well as others, depending on the project) contains generated
    toolchain configs that Bazel uses to properly use a toolchain. For instance,
    the local_config_cc repo generates a cc_toolchain rule.

    The toolchain configs that this rule produces, can be used to, for
    instance, use a remote execution service that runs actions inside docker
    containers.

    All the toolchain configs published in the bazel-toolchains
    repo (https://github.com/bazelbuild/bazel-toolchains/) have been produced
    using this rule.

    This rule is implemented by extending the container_image rule in
    https://github.com/bazelbuild/rules_docker. The rule installs debs packages
    to run bazel (using the package manager rules offered by
    https://github.com/GoogleCloudPlatform/distroless/tree/master/package_manager).
    The rule creates the container with a command that pulls a repo from github,
    and runs bazel build for a series of remote repos. Files generated in these
    repos are copied to a mount point inside the Bazel output tree, and finally
    copied to the /tmp directory or to the DOCKER_AUTOCONF_OUTPUT directory
    if passed as build variable.

    Args:
      **kwargs:
    Required Args
      name: A unique name for this rule.
      base: Docker image base - optionally with all tools pre-installed for
          which a configuration will be generated. Packages can also be installed
          by listing them in the 'packages' attriute.
    Default Args:
      config_repos: a list of remote repositories. Autoconfig will run targets in
          each of these remote repositories and copy all contents to the mount
          point.
      env: Dictionary of env variables for Bazel / project specific autoconfigure
      git_repo: A git repo with the sources for the project to be used for
          autoconfigure. If no git_repo is passed, autoconfig will run with a
          sample c++ project.
      bazel_version: a specific version of Bazel used to generate toolchain
          configs. Format: x.x.x
      bazel_rc_version: a specific version of Bazel release candidate used to
          generate toolchain configs. Input "2" if you would like to use rc2.
      use_bazel_head = Download bazel head from github, compile it and use it
          to run autoconfigure targets.
      setup_cmd: a customized command that will run as the very first command
          inside the docker container.
      packages: list of packages to fetch and install in the base image.
      additional_repos: list of additional debian package repos to use,
          in sources.list format.
      keys: list of additional gpg keys to use while downloading packages.
      incompatible_changes_off: If True Bazel will run without the
          all_incompatible_changes flag. Default False.
      test: a boolean which specifies whether a test target for this
          docker_toolchain_autoconfig will be added.
          If True, a test target with name {name}_test will be added.
          The test will build this docker_toolchain_autoconfig target, run the
          output script, and check the toolchain configs for the c++ auto
          generated config exist.
    """
    for reserved in reserved_attrs:
        if reserved in kwargs:
            fail("reserved for internal use by docker_toolchain_autoconfig macro", attr = reserved)

    for required in required_attrs:
        if required not in kwargs:
            fail("required for docker_toolchain_autoconfig", attr = required)

    # Input validations
    if "use_bazel_head" in kwargs and ("bazel_version" in kwargs or "bazel_rc_version" in kwargs):
        fail("Only one of use_bazel_head or a combination of bazel_version and" +
             "bazel_rc_version can be set at a time.")

    packages_is_empty = "packages" not in kwargs or kwargs["packages"] == []

    if packages_is_empty and "additional_repos" in kwargs:
        fail("'additional_repos' can only be specified when 'packages' is not empty.")
    if packages_is_empty and "keys" in kwargs:
        fail("'keys' can only be specified when 'packages' is not empty.")

    # If the git_repo was not provided, use the default autoconfig project
    if "git_repo" not in kwargs:
        kwargs["repo_pkg_tar"] = _DEFAULT_AUTOCONFIG_PROJECT_PKG_TAR
        kwargs["use_default_project"] = True
    kwargs["files"] = [
        _WORKSPACE_PREFIX + "rules:install_bazel_head.sh",
        _WORKSPACE_PREFIX + "rules:install_bazel_version.sh",
    ]

    # The template for the main script to execute for this rule, which produces
    # the toolchain configs
    kwargs["run_tpl"] = _WORKSPACE_PREFIX + "rules:docker_config.sh.tpl"

    # Do not install packags if 'packages' is not specified or is an ampty list.
    if not packages_is_empty:
        # "additional_repos" and "keys" are optional for docker_toolchain_autoconfig,
        # but required for toolchain_container". Use empty lists as placeholder.
        if "additional_repos" not in kwargs:
            kwargs["additional_repos"] = []
        if "keys" not in kwargs:
            kwargs["keys"] = []

        # Install packages in the base image.
        toolchain_container(
            name = kwargs["name"] + "_image",
            base = kwargs["base"],
            packages = kwargs["packages"],
            additional_repos = kwargs["additional_repos"],
            keys = kwargs["keys"],
        )

        # Use the image with packages installed as the new base for autoconfiguring.
        kwargs["base"] = ":" + kwargs["name"] + "_image.tar"

    if "test" in kwargs and kwargs["test"] == True:
        # Create a test target for the current docker_toolchain_autoconfig target,
        # which builds this docker_toolchain_autoconfig target, runs the output
        # script, and checks the toolchain configs for the c++ auto generated config
        # exist.
        native.sh_test(
            name = kwargs["name"] + "_test",
            size = "medium",
            timeout = "long",
            srcs = ["//test/configs:autoconfig_test.sh"],
            data = [":" + kwargs["name"]],
        )

    docker_toolchain_autoconfig_(**kwargs)
