Warning
This document is for an in-development version of Galaxy. You can alternatively view this page in the latest release if it exists or view the top of the latest release's documentation.
Source code for galaxy.tool_util.deps.mulled.mulled_build
#!/usr/bin/env python
"""Build a mulled image for specified conda targets.
Examples
Build a mulled image with:
mulled-build build 'samtools=1.3.1--4,bedtools=2.22'
"""
import json
import logging
import os
import shutil
import stat
import string
import subprocess
import sys
from sys import platform as _platform
from typing import (
Any,
Dict,
Iterable,
List,
)
import yaml
from galaxy.tool_util.deps import installable
from galaxy.tool_util.deps.conda_util import (
best_search_result,
CondaContext,
CondaTarget,
)
from galaxy.tool_util.deps.docker_util import command_list as docker_command_list
from galaxy.util import (
commands,
download_to_file,
safe_makedirs,
shlex_join,
unicodify,
)
from ._cli import arg_parser
from .util import (
build_target,
conda_build_target_str,
create_repository,
default_mulled_conda_channels_from_env,
get_files_from_conda_package,
PrintProgress,
quay_repository,
v1_image_name,
v2_image_name,
)
from ..conda_compat import MetaData
log = logging.getLogger(__name__)
INVFILE = os.environ.get("INVFILE", os.path.join(os.path.dirname(__file__), "invfile.lua"))
DEFAULT_BASE_IMAGE = os.environ.get("DEFAULT_BASE_IMAGE", "quay.io/bioconda/base-glibc-busybox-bash:latest")
DEFAULT_EXTENDED_BASE_IMAGE = os.environ.get(
"DEFAULT_EXTENDED_BASE_IMAGE", "quay.io/bioconda/base-glibc-debian-bash:latest"
)
DEFAULT_CHANNELS = default_mulled_conda_channels_from_env() or ["conda-forge", "bioconda"]
DEFAULT_REPOSITORY_TEMPLATE = "quay.io/${namespace}/${image}"
DEFAULT_BINDS = ["build/dist:/usr/local/"]
DEFAULT_WORKING_DIR = "/source/"
IS_OS_X = _platform == "darwin"
INVOLUCRO_VERSION = "1.1.2"
DEST_BASE_IMAGE = os.environ.get("DEST_BASE_IMAGE", None)
CONDA_IMAGE = os.environ.get("CONDA_IMAGE", None)
SINGULARITY_TEMPLATE = """Bootstrap: docker
From: %(base_image)s
%%setup
echo "Copying conda environment"
mkdir -p /tmp/conda
cp -r /data/dist/* /tmp/conda/
%%post
rm -R /usr/local || true
mkdir -p /usr/local
cp -R /tmp/conda/* /usr/local/
%%test
%(container_test)s
"""
def involucro_link():
if IS_OS_X:
url = f"https://github.com/mvdbeek/involucro/releases/download/v{INVOLUCRO_VERSION}/involucro.darwin"
else:
url = f"https://github.com/involucro/involucro/releases/download/v{INVOLUCRO_VERSION}/involucro"
return url
def get_tests(args, pkg_path):
"""Extract test cases given a recipe's meta.yaml file."""
recipes_dir = args.recipes_dir
tests = []
input_dir = os.path.dirname(os.path.join(recipes_dir, pkg_path))
recipe_meta = MetaData(input_dir)
tests_commands = recipe_meta.get_value("test/commands")
tests_imports = recipe_meta.get_value("test/imports")
requirements = recipe_meta.get_value("requirements/run")
if tests_imports or tests_commands:
if tests_commands:
tests.append(" && ".join(tests_commands))
if tests_imports and "python" in requirements:
tests.append(" && ".join(f'python -c "import {imp}"' for imp in tests_imports))
elif tests_imports and ("perl" in requirements or "perl-threaded" in requirements):
tests.append(" && ".join(f'''perl -e "use {imp};\"''' for imp in tests_imports))
tests = " && ".join(tests)
tests = tests.replace("$R ", "Rscript ")
return tests
def get_pkg_name(args, pkg_path):
"""Extract the package name from a given meta.yaml file."""
recipes_dir = args.recipes_dir
input_dir = os.path.dirname(os.path.join(recipes_dir, pkg_path))
recipe_meta = MetaData(input_dir)
return recipe_meta.get_value("package/name")
def get_affected_packages(args):
"""Return a list of all meta.yaml file that where modified/created recently.
Length of time to check for indicated by the ``hours`` parameter.
"""
recipes_dir = args.recipes_dir
hours = args.diff_hours
cmd = ["git", "log", "--diff-filter=ACMRTUXB", "--name-only", '--pretty=""', f'--since="{hours} hours ago"']
changed_files = unicodify(subprocess.check_output(cmd, cwd=recipes_dir)).splitlines()
pkg_list = {x for x in changed_files if x.startswith("recipes/") and x.endswith("meta.yaml")}
for pkg in pkg_list:
if pkg and os.path.exists(os.path.join(recipes_dir, pkg)):
yield (get_pkg_name(args, pkg), get_tests(args, pkg))
def conda_versions(pkg_name, file_name):
"""Return all conda version strings for a specified package name."""
j = json.load(open(file_name))
ret = []
for pkg in j["packages"].values():
if pkg["name"] == pkg_name:
ret.append(f"{pkg['version']}--{pkg['build']}")
return ret
def get_conda_hits_for_targets(targets: Iterable[CondaTarget], conda_context: CondaContext) -> List[Dict[str, Any]]:
search_results = (best_search_result(t, conda_context, platform="linux-64")[0] for t in targets)
return [r for r in search_results if r]
def base_image_for_targets(targets: Iterable[CondaTarget], conda_context: CondaContext) -> str:
"""
determine base image (DEFAULT_BASE_IMAGE/DEFAULT_EXTENDED_BASE_IMAGE) for a
list of targets by inspecting the conda package (i.e. if the use of an
extended image is indicated in info/about.json or info/recipe/meta.yaml
"""
hits = get_conda_hits_for_targets(targets, conda_context)
for hit in hits:
try:
content_dict = get_files_from_conda_package(hit["url"], ["info/about.json", "info/recipe/meta.yaml"])
if "info/about.json" in content_dict and json.loads(unicodify(content_dict["info/about.json"])).get(
"extra", {}
).get("container", {}).get("extended-base", False):
return DEFAULT_EXTENDED_BASE_IMAGE
elif "info/recipe/meta.yaml" in content_dict and (
yaml.safe_load(unicodify(content_dict["info/recipe/meta.yaml"]))
.get("extra", {})
.get("container", {})
.get("extended-base", False)
):
return DEFAULT_EXTENDED_BASE_IMAGE
except Exception:
log.warning(
"Could not load metadata.yaml for '%s', version '%s'", hit["name"], hit["version"], exc_info=True
)
return DEFAULT_BASE_IMAGE
class BuildExistsException(Exception):
"""Exception indicating mull_targets is skipping an existing build.
If mull_targets is called with rebuild=False and the target built is already published
an instance of this exception is thrown.
"""
def mull_targets(
targets: List[CondaTarget],
involucro_context=None,
command="build",
channels=DEFAULT_CHANNELS,
namespace="biocontainers",
test="true",
test_files=None,
image_build=None,
name_override=None,
repository_template=DEFAULT_REPOSITORY_TEMPLATE,
dry_run=False,
conda_version=None,
mamba_version=None,
use_mamba=False,
verbose=False,
binds=DEFAULT_BINDS,
rebuild=True,
oauth_token=None,
hash_func="v2",
singularity=False,
singularity_image_dir="singularity_import",
base_image=None,
determine_base_image=True,
invfile=INVFILE,
):
if involucro_context is None:
involucro_context = InvolucroContext()
image_function = v1_image_name if hash_func == "v1" else v2_image_name
if len(targets) > 1 and image_build is None:
# Force an image build in this case - this seems hacky probably
# shouldn't work this way but single case broken else wise.
image_build = "0"
repo_template_kwds = {
"namespace": namespace,
"image": image_function(targets, image_build=image_build, name_override=name_override),
}
repo = string.Template(repository_template).safe_substitute(repo_template_kwds)
if not rebuild or "push" in command:
repo_name = repo_template_kwds["image"].split(":", 1)[0]
repo_data = quay_repository(repo_template_kwds["namespace"], repo_name)
if not rebuild:
tags = repo_data.get("tags", [])
target_tag = None
if ":" in repo_template_kwds["image"]:
image_name_parts = repo_template_kwds["image"].split(":")
assert len(image_name_parts) == 2, f": not allowed in image name [{repo_template_kwds['image']}]"
target_tag = image_name_parts[1]
if tags and (target_tag is None or target_tag in tags):
raise BuildExistsException()
if "push" in command and "error_type" in repo_data and oauth_token:
# Explicitly create the repository so it can be built as public.
create_repository(repo_template_kwds["namespace"], repo_name, oauth_token)
for channel in channels:
if channel.startswith("file://"):
bind_path = channel[7:]
binds.append(f"{bind_path}:{bind_path}")
channels_str = ",".join(channels)
target_str = ",".join(map(conda_build_target_str, targets))
bind_str = ",".join(binds)
involucro_args = [
"-f",
invfile,
"-set",
f"CHANNELS={channels_str}",
"-set",
f"TARGETS={target_str}",
"-set",
f"REPO={repo}",
"-set",
f"BINDS={bind_str}",
]
dest_base_image = None
if base_image:
dest_base_image = base_image
elif DEST_BASE_IMAGE:
dest_base_image = DEST_BASE_IMAGE
elif determine_base_image:
conda_context = CondaInDockerContext(ensure_channels=channels)
dest_base_image = base_image_for_targets(targets, conda_context)
if dest_base_image:
involucro_args.extend(["-set", f"DEST_BASE_IMAGE={dest_base_image}"])
if CONDA_IMAGE:
involucro_args.extend(["-set", f"CONDA_IMAGE={CONDA_IMAGE}"])
if verbose:
involucro_args.extend(["-set", "VERBOSE=1"])
if singularity:
singularity_image_name = repo_template_kwds["image"]
involucro_args.extend(["-set", "SINGULARITY=1"])
involucro_args.extend(["-set", f"SINGULARITY_IMAGE_NAME={singularity_image_name}"])
involucro_args.extend(["-set", f"SINGULARITY_IMAGE_DIR={singularity_image_dir}"])
involucro_args.extend(["-set", f"USER_ID={os.getuid()}:{os.getgid()}"])
if test:
involucro_args.extend(["-set", f"TEST={test}"])
verbose = "--verbose" if verbose else "--quiet"
conda_bin = "conda"
if use_mamba:
conda_bin = "mamba"
if mamba_version is None:
mamba_version = ""
involucro_args.extend(["-set", f"CONDA_BIN={conda_bin}"])
if conda_version is not None or mamba_version is not None:
mamba_test = "true"
specs = []
if conda_version is not None:
specs.append(f"conda={conda_version}")
if mamba_version is not None:
specs.append(f"mamba={mamba_version}")
if mamba_version == "" and not specs:
# If nothing but mamba without a specific version is requested,
# then only run conda install if mamba is not already installed.
mamba_test = "[ '[]' = \"$( conda list --json --full-name mamba )\" ]"
conda_install = f"""conda install {verbose} --yes {" ".join(f"'{spec}'" for spec in specs)}"""
involucro_args.extend(["-set", f"PREINSTALL=if {mamba_test} ; then {conda_install} ; fi"])
involucro_args.append(command)
if test_files:
test_bind = []
for test_file in test_files:
if ":" not in test_file:
if os.path.exists(test_file):
test_bind.append(f"{test_file}:{DEFAULT_WORKING_DIR}/{test_file}")
else:
if os.path.exists(test_file.split(":")[0]):
test_bind.append(test_file)
if test_bind:
involucro_args.insert(6, "-set")
involucro_args.insert(7, f"TEST_BINDS={','.join(test_bind)}")
cmd = involucro_context.build_command(involucro_args)
print(f"Executing: {shlex_join(cmd)}")
if dry_run:
return 0
ensure_installed(involucro_context, True)
if singularity:
if not os.path.exists(singularity_image_dir):
safe_makedirs(singularity_image_dir)
with open(os.path.join(singularity_image_dir, "Singularity.def"), "w+") as sin_def:
fill_template = SINGULARITY_TEMPLATE % {
"container_test": test,
"base_image": dest_base_image or DEFAULT_BASE_IMAGE,
}
sin_def.write(fill_template)
with PrintProgress():
ret = involucro_context.exec_command(involucro_args)
if singularity:
# we can not remove this folder as it contains the image wich is owned by root
pass
# shutil.rmtree('./singularity_import')
return ret
def context_from_args(args):
verbose = "2" if not args.verbose else "3"
return InvolucroContext(involucro_bin=args.involucro_path, verbose=verbose)
class CondaInDockerContext(CondaContext):
def __init__(
self,
conda_prefix=None,
conda_exec=None,
shell_exec=None,
debug=False,
ensure_channels=DEFAULT_CHANNELS,
condarc_override=None,
):
if not conda_exec:
conda_image = CONDA_IMAGE or "continuumio/miniconda3:latest"
binds = []
for channel in ensure_channels:
if channel.startswith("file://"):
bind_path = channel[7:]
binds.extend(["-v", f"{bind_path}:{bind_path}"])
conda_exec = docker_command_list("run", binds + [conda_image, "conda"])
super().__init__(
conda_prefix=conda_prefix,
conda_exec=conda_exec,
shell_exec=shell_exec,
debug=debug,
ensure_channels=ensure_channels,
condarc_override=condarc_override,
)
class InvolucroContext(installable.InstallableContext):
installable_description = "Involucro"
def __init__(self, involucro_bin=None, shell_exec=None, verbose="3"):
if involucro_bin is None:
if os.path.exists("./involucro"):
self.involucro_bin = "./involucro"
else:
self.involucro_bin = "involucro"
else:
self.involucro_bin = involucro_bin
self.shell_exec = shell_exec or commands.shell
self.verbose = verbose
def build_command(self, involucro_args):
return [self.involucro_bin, f"-v={self.verbose}"] + involucro_args
def exec_command(self, involucro_args):
cmd = self.build_command(involucro_args)
# Create ./build dir manually, otherwise Docker will do it as root
created_build_dir = False
if not os.path.exists("build"):
created_build_dir = True
os.mkdir("./build")
try:
res = self.shell_exec(cmd)
finally:
# delete build directory in any case
if created_build_dir:
shutil.rmtree("./build")
return res
def is_installed(self):
return os.path.exists(self.involucro_bin)
def can_install(self):
return True
@property
def parent_path(self):
return os.path.dirname(os.path.abspath(self.involucro_bin))
def ensure_installed(involucro_context, auto_init):
return installable.ensure_installed(involucro_context, install_involucro, auto_init)
def install_involucro(involucro_context):
install_path = os.path.abspath(involucro_context.involucro_bin)
involucro_context.involucro_bin = install_path
try:
download_to_file(involucro_link(), install_path)
except Exception:
log.exception(f"Failed to download involucro from url '{involucro_link()}'")
return 1
try:
os.chmod(install_path, os.stat(install_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return 0
except Exception:
log.exception("Failed to make file '%s' executable", install_path)
return 1
def add_build_arguments(parser):
"""Base arguments describing how to 'mull'."""
parser.add_argument(
"--involucro-path",
dest="involucro_path",
default=None,
help="Path to involucro (if not set will look in working directory and on PATH).",
)
parser.add_argument(
"--dry-run", dest="dry_run", action="store_true", help="Just print commands instead of executing them."
)
parser.add_argument("--verbose", dest="verbose", action="store_true", help="Cause process to be verbose.")
parser.add_argument("--singularity", action="store_true", help="Additionally build a singularity image.")
parser.add_argument(
"--singularity-image-dir", dest="singularity_image_dir", help="Directory to write singularity images too."
)
parser.add_argument("--involucro-lua-file", dest="invfile", default=INVFILE, help="Path to invfile.lua")
parser.add_argument("-n", "--namespace", dest="namespace", default="biocontainers", help="quay.io namespace.")
parser.add_argument(
"-r",
"--repository_template",
dest="repository_template",
default=DEFAULT_REPOSITORY_TEMPLATE,
help="Docker repository target for publication (only quay.io or compat. API is currently supported).",
)
parser.add_argument(
"-c",
"--channels",
dest="channels",
default=",".join(DEFAULT_CHANNELS),
help="Comma separated list of target conda channels.",
)
parser.add_argument(
"--conda-version",
dest="conda_version",
default=None,
help="Change to specified version of Conda before installing packages.",
)
parser.add_argument(
"--mamba-version",
dest="mamba_version",
default=None,
help="Change to specified version of Mamba before installing packages.",
)
parser.add_argument(
"--use-mamba",
dest="use_mamba",
action="store_true",
help="Use Mamba instead of Conda for package installation.",
)
parser.add_argument(
"--oauth-token",
dest="oauth_token",
default=None,
help="If set, use this token when communicating with quay.io API.",
)
parser.add_argument("--check-published", dest="rebuild", action="store_false")
parser.add_argument("--hash", dest="hash", choices=["v1", "v2"], default="v2")
def add_single_image_arguments(parser):
parser.add_argument(
"--name-override",
dest="name_override",
default=None,
help="Override mulled image name - this is not recommended since metadata will not be detectable from the name of resulting images",
)
parser.add_argument(
"--image-build", dest="image_build", default=None, help="Build a versioned variant of this image."
)
def target_str_to_targets(targets_raw):
def parse_target(target_str):
if "=" in target_str:
package_name, version = target_str.split("=", 1)
build = None
if "=" in version:
version, build = version.split("=")
elif "--" in version:
version, build = version.split("--")
target = build_target(package_name, version, build)
else:
target = build_target(target_str)
return target
targets = [parse_target(_) for _ in targets_raw.split(",")]
return targets
def args_to_mull_targets_kwds(args):
kwds = {}
if hasattr(args, "image_build"):
kwds["image_build"] = args.image_build
if hasattr(args, "name_override"):
kwds["name_override"] = args.name_override
if hasattr(args, "namespace"):
kwds["namespace"] = args.namespace
if hasattr(args, "dry_run"):
kwds["dry_run"] = args.dry_run
if hasattr(args, "singularity"):
kwds["singularity"] = args.singularity
if hasattr(args, "test"):
kwds["test"] = args.test
if hasattr(args, "test_files"):
if args.test_files:
kwds["test_files"] = args.test_files.split(",")
if hasattr(args, "channels"):
kwds["channels"] = args.channels.split(",")
if hasattr(args, "command"):
kwds["command"] = args.command
if hasattr(args, "repository_template"):
kwds["repository_template"] = args.repository_template
if hasattr(args, "conda_version"):
kwds["conda_version"] = args.conda_version
if hasattr(args, "mamba_version"):
kwds["mamba_version"] = args.mamba_version
if hasattr(args, "use_mamba"):
kwds["use_mamba"] = args.use_mamba
if hasattr(args, "oauth_token"):
kwds["oauth_token"] = args.oauth_token
if hasattr(args, "rebuild"):
kwds["rebuild"] = args.rebuild
if hasattr(args, "hash"):
kwds["hash_func"] = args.hash
if hasattr(args, "singularity_image_dir") and args.singularity_image_dir:
kwds["singularity_image_dir"] = args.singularity_image_dir
if hasattr(args, "invfile"):
kwds["invfile"] = args.invfile
kwds["involucro_context"] = context_from_args(args)
return kwds
[docs]def main(argv=None):
"""Main entry-point for the CLI tool."""
parser = arg_parser(argv, globals())
add_build_arguments(parser)
add_single_image_arguments(parser)
parser.add_argument("command", metavar="COMMAND", help="Command (build-and-test, build, all)")
parser.add_argument(
"targets", metavar="TARGETS", default=None, help="Build a single container with specific package(s)."
)
parser.add_argument(
"--repository-name",
dest="repository_name",
default=None,
help="Name of mulled container (leave blank to auto-generate based on packages - recommended).",
)
parser.add_argument("--test", help="Provide a test command for the container.")
parser.add_argument(
"--test-files",
help="Provide test-files that may be required to run the test command. Individual mounts are separated by comma."
"The source:dest docker syntax is respected. If relative file paths are given, files will be mounted in /source/<relative_file_path>",
)
args = parser.parse_args()
targets = target_str_to_targets(args.targets)
sys.exit(mull_targets(targets, **args_to_mull_targets_kwds(args)))
__all__ = (
"main",
"build_target",
)
if __name__ == "__main__":
main()