From 73d946bf7595f249649bb10a2d4f08391357dc51 Mon Sep 17 00:00:00 2001 From: vsoch Date: Sat, 30 May 2020 16:30:06 -0600 Subject: [PATCH 1/4] adding new multistage testing files Signed-off-by: vsoch --- CHANGELOG.md | 3 +- spython/main/parse/parsers/base.py | 60 +++++++- spython/main/parse/parsers/docker.py | 116 +++++++++----- spython/main/parse/parsers/singularity.py | 142 +++++++++--------- spython/main/parse/recipe.py | 8 +- spython/main/parse/writers/docker.py | 56 ++++--- spython/main/parse/writers/singularity.py | 97 +++++++----- spython/tests/test_conversion.py | 11 +- spython/tests/test_parsers.py | 42 +++--- .../tests/testdata/docker2singularity/add.def | 4 +- .../tests/testdata/docker2singularity/cmd.def | 4 +- .../testdata/docker2singularity/comments.def | 4 +- .../testdata/docker2singularity/copy.def | 4 +- .../docker2singularity/entrypoint-cmd.def | 2 + .../docker2singularity/entrypoint.def | 4 +- .../testdata/docker2singularity/expose.def | 4 +- .../testdata/docker2singularity/from.def | 4 +- .../docker2singularity/healthcheck.def | 4 +- .../testdata/docker2singularity/label.def | 4 +- .../docker2singularity/multiple-lines.def | 4 +- .../docker2singularity/multistage.def | 20 +++ .../docker2singularity/multistage.docker | 7 + .../testdata/docker2singularity/user.def | 4 +- .../testdata/docker2singularity/workdir.def | 4 +- .../testdata/singularity2docker/files.docker | 4 +- .../testdata/singularity2docker/from.docker | 2 +- .../testdata/singularity2docker/labels.docker | 4 +- .../singularity2docker/multiple-lines.docker | 4 +- .../singularity2docker/multistage.def | 19 +++ .../singularity2docker/multistage.docker | 7 + .../testdata/singularity2docker/post.docker | 4 +- .../singularity2docker/runscript.docker | 4 +- .../testdata/singularity2docker/test.docker | 4 +- spython/version.py | 2 +- 34 files changed, 446 insertions(+), 220 deletions(-) create mode 100644 spython/tests/testdata/docker2singularity/multistage.def create mode 100644 spython/tests/testdata/docker2singularity/multistage.docker create mode 100644 spython/tests/testdata/singularity2docker/multistage.def create mode 100644 spython/tests/testdata/singularity2docker/multistage.docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d073538..c4d49d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ The client here will eventually be released as "spython" (and eventually to singularity on pypi), and the versions here will coincide with these releases. ## [master](https://github.com/singularityhub/singularity-cli/tree/master) - - Singularity does not support multistage builds (0.0.82) + - Adding support for multistage build parsing (0.0.83) + - Singularity Python does not yet support multistage builds (0.0.82) - stream command should print to stdout given CalledProcessError (0.0.81) - USER regular expression should check for USER at start of line (0.0.80) - add singularity options parameters to send to singularity (0.0.79) diff --git a/spython/main/parse/parsers/base.py b/spython/main/parse/parsers/base.py index f727d745..b0cabb82 100644 --- a/spython/main/parse/parsers/base.py +++ b/spython/main/parse/parsers/base.py @@ -5,7 +5,9 @@ # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. import abc +from copy import deepcopy import os +import re from spython.logger import bot from spython.utils import read_file @@ -35,7 +37,14 @@ def __init__(self, filename, load=True): self.filename = filename self._run_checks() self.lines = [] - self.recipe = Recipe(self.filename) + + # Arguments can be used internally, active layer name and number + self.args = {} + self.active_layer = "spython-base" + self.active_layer_num = 1 + + # Support multistage builds + self.recipe = {"spython-base": Recipe(self.filename)} if self.filename: @@ -100,3 +109,52 @@ def _split_line(self, line): """ return [x.strip() for x in line.split(" ", 1)] + + # Multistage + + def _multistage(self, fromHeader): + """Given a from header, determine if we have a multistage build, and + update the recipe parser active in case that we do. If we are dealing + with the first layer and it's named, we also update the default + name "spython-base" to be what the recipe intended. + + Parameters + ========== + fromHeader: the fromHeader parsed from self.from, possibly with AS + """ + # Derive if there is a named layer + match = re.search("AS (?P.+)", fromHeader, flags=re.I) + if match: + layer = match.groups("layer")[0].strip() + + # If it's the first layer named incorrectly, we need to rename + if len(self.recipe) == 1 and list(self.recipe)[0] == "spython-base": + self.recipe[layer] = deepcopy(self.recipe[self.active_layer]) + del self.recipe[self.active_layer] + else: + self.active_layer_num += 1 + self.recipe[layer] = Recipe(self.filename, self.active_layer_num) + self.active_layer = layer + bot.debug( + "Active layer #%s updated to %s" + % (self.active_layer_num, self.active_layer) + ) + + def _replace_from_dict(self, string, args): + """Given a lookup of arguments, args, replace any that are found in + the given string. This is intended to be used to substitute ARGs + provided in a Dockerfile into other sections, e.g., FROM $BASE + + Parameters + ========== + string: an input string to look for replacements + args: a dictionary to make lookups from + + Returns + ======= + string: the string with replacements made + """ + for key, value in args.items(): + if re.search("\$(" + key + "|\{[^}]*\})", string): + string = re.sub("\$(" + key + "|\{[^}]*\})", value, string) + return string diff --git a/spython/main/parse/parsers/docker.py b/spython/main/parse/parsers/docker.py index b5e4e864..6b76bd8b 100644 --- a/spython/main/parse/parsers/docker.py +++ b/spython/main/parse/parsers/docker.py @@ -10,6 +10,7 @@ from spython.logger import bot from .base import ParserBase +from ..recipe import Recipe class DockerParser(ParserBase): @@ -80,21 +81,28 @@ def _setup(self, action, line): # From Parser def _from(self, line): - """ get the FROM container image name from a FROM line! - - Parameters - ========== - line: the line from the recipe file to parse for FROM - recipe: the recipe object to populate. + """ get the FROM container image name from a FROM line. If we have + already seen a FROM statement, this is indicative of adding + another image (multistage build). + + Parameters + ========== + line: the line from the recipe file to parse for FROM + recipe: the recipe object to populate. """ fromHeader = self._setup("FROM", line) - # Singularity does not support AS level - self.recipe.fromHeader = re.sub("AS .+", "", fromHeader[0], flags=re.I) + # Do we have a multistge build to update the active layer? + self._multistage(fromHeader[0]) + + # Now extract the from header, make args replacements + self.recipe[self.active_layer].fromHeader = self._replace_from_dict( + re.sub("AS .+", "", fromHeader[0], flags=re.I), self.args + ) - if "scratch" in self.recipe.fromHeader: + if "scratch" in self.recipe[self.active_layer].fromHeader: bot.warning("scratch is no longer available on Docker Hub.") - bot.debug("FROM %s" % self.recipe.fromHeader) + bot.debug("FROM %s" % self.recipe[self.active_layer].fromHeader) # Run and Test Parser @@ -107,7 +115,7 @@ def _run(self, line): """ line = self._setup("RUN", line) - self.recipe.install += line + self.recipe[self.active_layer].install += line def _test(self, line): """ A healthcheck is generally a test command @@ -117,7 +125,7 @@ def _test(self, line): line: the line from the recipe file to parse for FROM """ - self.recipe.test = self._setup("HEALTHCHECK", line) + self.recipe[self.active_layer].test = self._setup("HEALTHCHECK", line) # Arg Parser @@ -132,10 +140,27 @@ def _arg(self, line): """ line = self._setup("ARG", line) - bot.warning( - "ARG is not supported for Singularity! Add as ENV to the container or export" - " SINGULARITYENV_%s on the host." % line[0] - ) + + # Args are treated like envars, so we add them to install + environ = self.parse_env([x for x in line if "=" in x]) + self.recipe[self.active_layer].install += environ + + # Try to extract arguments from the line + for arg in line: + + # An undefined arg cannot be used + if "=" not in arg: + bot.warning( + "ARG is not supported for Singularity, and must be defined with " + "a default to be parsed. Skipping %s" % arg + ) + continue + + arg, value = arg.split("=", 1) + arg = arg.strip() + value = value.strip() + bot.debug("Updating ARG %s to %s" % (arg, value)) + self.args[arg] = value # Env Parser @@ -154,10 +179,10 @@ def _env(self, line): environ = self.parse_env(line) # Add to global environment, run during install - self.recipe.install += environ + self.recipe[self.active_layer].install += environ # Also define for global environment - self.recipe.environ += environ + self.recipe[self.active_layer].environ += environ def parse_env(self, envlist): """parse_env will parse a single line (with prefix like ENV removed) to @@ -219,18 +244,24 @@ def _copy(self, lines): for line in lines: - # Singularity does not support multistage builds + # Take into account multistage builds + layer = None if line.startswith("--from"): - bot.warning( - "Singularity does not support multistage builds, skipping COPY %s" - % line - ) - continue + layer = line.strip("--from").split(" ")[0].lstrip("=") + if layer not in self.recipe: + bot.warning( + "COPY requested from layer %s, but layer not previously defined." + % layer + ) + continue + + # Remove the --from from the line + line = " ".join([l for l in line.split(" ")[1:] if l]) values = line.split(" ") topath = values.pop() for frompath in values: - self._add_files(frompath, topath) + self._add_files(frompath, topath, layer) def _add(self, lines): """Add can also handle https, and compressed files. @@ -265,7 +296,7 @@ def _add(self, lines): # File Handling - def _add_files(self, source, dest): + def _add_files(self, source, dest, layer=None): """add files is the underlying function called to add files to the list, whether originally called from the functions to parse archives, or https. We make sure that any local references are changed to @@ -282,11 +313,18 @@ def _add_files(self, source, dest): bot.warning("Singularity doesn't support expansion, * found in %s" % source) # Warning if file/folder (src) doesn't exist - if not os.path.exists(source): + if not os.path.exists(source) and layer is None: bot.warning("%s doesn't exist, ensure exists for build" % source) # The pair is added to the files as a list - self.recipe.files.append([source, dest]) + if not layer: + self.recipe[self.active_layer].files.append([source, dest]) + + # Unless the file is to be copied from a particular layer + else: + if layer not in self.recipe[self.active_layer].layer_files: + self.recipe[self.active_layer].layer_files[layer] = [] + self.recipe[self.active_layer].layer_files[layer].append([source, dest]) def _parse_http(self, url, dest): """will get the filename of an http address, and return a statement @@ -301,7 +339,7 @@ def _parse_http(self, url, dest): file_name = os.path.basename(url) download_path = "%s/%s" % (dest, file_name) command = "curl %s -o %s" % (url, download_path) - self.recipe.install.append(command) + self.recipe[self.active_layer].install.append(command) def _parse_archive(self, targz, dest): """parse_targz will add a line to the install script to extract a @@ -315,7 +353,7 @@ def _parse_archive(self, targz, dest): """ # Add command to extract it - self.recipe.install.append("tar -zvf %s %s" % (targz, dest)) + self.recipe[self.active_layer].install.append("tar -zvf %s %s" % (targz, dest)) # Ensure added to container files return self._add_files(targz, dest) @@ -332,7 +370,7 @@ def _comment(self, line): line: the line from the recipe file to parse to INSTALL """ - self.recipe.install.append(line) + self.recipe[self.active_layer].install.append(line) def _default(self, line): """the default action assumes a line that is either a command (a @@ -344,7 +382,7 @@ def _default(self, line): """ if line.strip().startswith("#"): return self._comment(line) - self.recipe.install.append(line) + self.recipe[self.active_layer].install.append(line) # Ports and Volumes @@ -360,7 +398,7 @@ def _volume(self, line): """ volumes = self._setup("VOLUME", line) if volumes: - self.recipe.volumes += volumes + self.recipe[self.active_layer].volumes += volumes return self._comment("# %s" % line) def _expose(self, line): @@ -373,7 +411,7 @@ def _expose(self, line): """ ports = self._setup("EXPOSE", line) if ports: - self.recipe.ports += ports + self.recipe[self.active_layer].ports += ports return self._comment("# %s" % line) # Working Directory @@ -389,8 +427,8 @@ def _workdir(self, line): # Save the last working directory to add to the runscript workdir = self._setup("WORKDIR", line) workdir_cd = "cd %s" % ("".join(workdir)) - self.recipe.install.append(workdir_cd) - self.recipe.workdir = workdir[0] + self.recipe[self.active_layer].install.append(workdir_cd) + self.recipe[self.active_layer].workdir = workdir[0] # Entrypoint and Command @@ -406,7 +444,7 @@ def _cmd(self, line): """ cmd = self._setup("CMD", line)[0] - self.recipe.cmd = self._load_list(cmd) + self.recipe[self.active_layer].cmd = self._load_list(cmd) def _load_list(self, line): """load an entrypoint or command, meaning it can be wrapped in a list @@ -430,7 +468,7 @@ def _entry(self, line): """ entrypoint = self._setup("ENTRYPOINT", line)[0] - self.recipe.entrypoint = self._load_list(entrypoint) + self.recipe[self.active_layer].entrypoint = self._load_list(entrypoint) # Labels @@ -443,7 +481,7 @@ def _label(self, line): """ label = self._setup("LABEL", line) - self.recipe.labels += [label] + self.recipe[self.active_layer].labels += [label] # Main Parsing Functions diff --git a/spython/main/parse/parsers/singularity.py b/spython/main/parse/parsers/singularity.py index 270e3df8..3ecbfece 100644 --- a/spython/main/parse/parsers/singularity.py +++ b/spython/main/parse/parsers/singularity.py @@ -38,21 +38,7 @@ def parse(self): cd first in a line is parsed as WORKDIR """ - # If the recipe isn't loaded, load it - if not hasattr(self, "config"): - self.load_recipe() - - # Parse each section - for section, lines in self.config.items(): - bot.debug(section) - - # Get the correct parsing function - parser = self._get_mapping(section) - - # Parse it, if appropriate - if parser: - parser(lines) - + self.load_recipe() return self.recipe # Setup for each Parser @@ -91,8 +77,8 @@ def _from(self, line): line: the line from the recipe file to parse for FROM """ - self.recipe.fromHeader = line - bot.debug("FROM %s" % self.recipe.fromHeader) + self.recipe[self.active_layer].fromHeader = line + bot.debug("FROM %s" % self.recipe[self.active_layer].fromHeader) # Run and Test Parser @@ -105,7 +91,7 @@ def _test(self, lines): """ self._write_script("/tests.sh", lines) - self.recipe.test = "/bin/bash /tests.sh" + self.recipe[self.active_layer].test = "/bin/bash /tests.sh" # Env Parser @@ -120,11 +106,11 @@ def _env(self, lines): """ environ = [re.sub("^export", "", x).strip() for x in lines if "=" in x] - self.recipe.environ += environ + self.recipe[self.active_layer].environ += environ # Files for container - def _files(self, lines): + def _files(self, lines, layer=None): """parse_files will simply add the list of files to the correct object Parameters @@ -132,7 +118,12 @@ def _files(self, lines): lines: pairs of files, one pair per line """ - self.recipe.files += lines + if not layer: + self.recipe[self.active_layer].files += lines + else: + if layer not in self.recipe[self.active_layer].layer_files: + self.recipe[self.active_layer].layer_files[layer] = [] + self.recipe[self.active_layer].layer_files[layer] += lines # Comments and Help @@ -147,7 +138,8 @@ def _comments(self, lines): """ for line in lines: comment = self._comment(line) - self.recipe.comments.append(comment) + if comment not in self.recipe[self.active_layer].comments: + self.recipe[self.active_layer].comments.append(comment) def _comment(self, line): """Simply add the line to the install as a comment. Add an extra # to be @@ -184,7 +176,7 @@ def _run(self, lines): self._write_script("/entrypoint.sh", lines) runscript = "/bin/bash /entrypoint.sh" - self.recipe.cmd = runscript + self.recipe[self.active_layer].cmd = runscript # Labels @@ -196,7 +188,7 @@ def _labels(self, lines): lines: the lines from the recipe with key,value pairs """ - self.recipe.labels += lines + self.recipe[self.active_layer].labels += lines def _post(self, lines): """the main core of commands, to be added to the install section @@ -206,7 +198,7 @@ def _post(self, lines): lines: the lines from the recipe with install commands """ - self.recipe.install += lines + self.recipe[self.active_layer].install += lines # Main Parsing Functions @@ -252,17 +244,16 @@ def _load_from(self, line): """ # Remove any comments line = line.split("#", 1)[0] - line = re.sub("(F|f)(R|r)(O|o)(M|m):", "", line).strip() - self.config["from"] = line + line = re.sub("from:", "", line.lower()).strip() + self.recipe[self.active_layer].fromHeader = line - def _load_bootstrap(self, line): - """load bootstrap checks that the bootstrap is Docker, otherwise we - exit on fail (there is no other option to convert to Dockerfile! + def _check_bootstrap(self, line): + """checks that the bootstrap is Docker, otherwise we exit on fail. """ - if "docker" not in line.lower(): - raise NotImplementedError("docker not detected as Bootstrap!") + if not re.search("docker", line, re.IGNORECASE): + raise NotImplementedError("Only docker is supported.") - def _load_section(self, lines, section): + def _load_section(self, lines, section, layer=None): """read in a section to a list, and stop when we hit the next section """ members = [] @@ -273,6 +264,10 @@ def _load_section(self, lines, section): break next_line = lines[0] + # We have a start of another bootstrap + if re.search("bootstrap:", next_line, re.IGNORECASE): + break + # The end of a section if next_line.strip().startswith("%"): break @@ -284,9 +279,19 @@ def _load_section(self, lines, section): members.append(new_member) # Add the list to the config - if members: - if section is not None: - self.config[section] += members + if members and section is not None: + + # Get the correct parsing function + parser = self._get_mapping(section) + + # Parse it, if appropriate + if not parser: + bot.warning("%s is an unrecognized section, skipping." % section) + else: + if section == "files": + parser(members, layer) + else: + parser(members) def load_recipe(self): """load_recipe will return a loaded in singularity recipe. The idea @@ -300,12 +305,8 @@ def load_recipe(self): # Comments between sections, add to top of file lines = self.lines[:] - comments = [] - - # Start with a fresh config! - self.config = dict() - - section = None + fromHeader = None + stage = None while lines: @@ -314,55 +315,58 @@ def load_recipe(self): stripped = line.strip() # Bootstrap Line - if re.search("(b|B)(o|O){2}(t|T)(s|S)(t|T)(r|R)(a|A)(p|P)", line): - self._load_bootstrap(stripped) + if re.search("bootstrap", line, re.IGNORECASE): + self._check_bootstrap(stripped) + section = None + comments = [] # From Line - if re.search("(f|F)(r|R)(O|o)(m|M)", stripped): - self._load_from(stripped) + elif re.search("from:", stripped, re.IGNORECASE): + fromHeader = stripped + if stage is None: + self._load_from(fromHeader) + + # Identify stage + elif re.search("stage:", stripped, re.IGNORECASE): + stage = re.sub("stage:", "", stripped.lower()).strip() + self._multistage("as %s" % stage) + self._load_from(fromHeader) # Comment - if stripped.startswith("#"): + elif stripped.startswith("#") and stripped not in comments: comments.append(stripped) - continue # Section elif stripped.startswith("%"): - section = self._add_section(stripped) - bot.debug("Adding section title %s" % section) + section, layer = self._get_section(stripped) + bot.debug("Found section %s" % section) # If we have a section, and are adding it elif section is not None: lines = [line] + lines - self._load_section(lines=lines, section=section) + self._load_section(lines=lines, section=section, layer=layer) - self.config["comments"] = comments + self._comments(comments) - def _add_section(self, line, section=None): - """parse a line for a section, and return the parsed section (if not - None) + def _get_section(self, line): + """parse a line for a section, and return the name of the section Parameters ========== line: the line to parse - section: the current (or previous) section - - Resulting data structure is: - config['post'] (in lowercase) - """ # Remove any comments line = line.split("#", 1)[0].strip() # Is there a section name? - parts = line.split(" ") + parts = [l.strip() for l in line.split(" ") if l] section = re.sub(r"[%]|(\s+)", "", parts[0]).lower() - if section not in self.config: - self.config[section] = [] - bot.debug("Adding section %s" % section) - - return section + # Is there a named layer? + layer = None + if len(parts) == 3 and parts[1].lower() == "from": + layer = parts[2] + return section, layer def _write_script(self, path, lines, chmod=True): """write a script with some lines content to path in the image. This @@ -376,7 +380,9 @@ def _write_script(self, path, lines, chmod=True): """ for line in lines: - self.recipe.install.append('echo "%s" >> %s' % (line, path)) + self.recipe[self.active_layer].install.append( + 'echo "%s" >> %s' % (line, path) + ) if chmod: - self.recipe.install.append("chmod u+x %s" % path) + self.recipe[self.active_layer].install.append("chmod u+x %s" % path) diff --git a/spython/main/parse/recipe.py b/spython/main/parse/recipe.py index 22d58378..a367a847 100644 --- a/spython/main/parse/recipe.py +++ b/spython/main/parse/recipe.py @@ -17,22 +17,25 @@ class Recipe(object): ========== recipe: the original recipe file, parsed by the subclass either DockerParser or SingularityParser + layer: the count of the layer, for human readability """ - def __init__(self, recipe=None): + def __init__(self, recipe=None, layer=1): self.cmd = None self.comments = [] self.entrypoint = None self.environ = [] self.files = [] + self.layer_files = {} self.install = [] self.labels = [] self.ports = [] self.test = None self.volumes = [] self.workdir = None + self.layer = layer self.source = recipe @@ -54,7 +57,8 @@ def json(self): Returns: a dictionary of attributes including cmd, comments, entrypoint, environ, files, install, labels, ports, - test, volumes, and workdir. + test, volumes, and workdir, organized by layer for + multistage builds. """ attributes = [ "cmd", diff --git a/spython/main/parse/writers/docker.py b/spython/main/parse/writers/docker.py index ce7701ee..e4f8c2a4 100644 --- a/spython/main/parse/writers/docker.py +++ b/spython/main/parse/writers/docker.py @@ -66,19 +66,22 @@ def validate(self): if self.recipe is None: bot.exit("Please provide a Recipe() to the writer first.") - if self.recipe.fromHeader is None: + def validate_stage(self, parser): + """Given a recipe parser for a stage, ensure that the recipe is valid + """ + if parser.fromHeader is None: bot.exit("Dockerfile requires a fromHeader.") # Parse the provided name uri_regexes = [_reduced_uri, _default_uri, _docker_uri] for r in uri_regexes: - match = r.match(self.recipe.fromHeader) + match = r.match(parse.fromHeader) if match: break if not match: - bot.exit("FROM header %s not valid." % self.recipe.fromHeader) + bot.exit("FROM header %s not valid." % parser.fromHeader) def convert(self, runscript="/bin/bash", force=False): """convert is called by the parent class to convert the recipe object @@ -86,34 +89,41 @@ def convert(self, runscript="/bin/bash", force=False): """ self.validate() - recipe = ["FROM %s" % self.recipe.fromHeader] + recipe = [] + for stage, parser in self.recipe.items(): + self.validate_stage(parser) + + recipe += ["FROM %s AS %s" % (parser.fromHeader, stage)] - # Comments go up front! - recipe += self.recipe.comments + # First add files, labels, environment + recipe += write_files("ADD", parser.files) + recipe += write_lines("LABEL", parser.labels) + recipe += write_lines("ENV", parser.environ) - # First add files, labels, environment - recipe += write_files("ADD", self.recipe.files) - recipe += write_lines("LABEL", self.recipe.labels) - recipe += write_lines("ENV", self.recipe.environ) + # Handle custom files from other layers + for layer, files in parser.layer_files.items(): + for pair in files: + recipe += ["COPY --from=%s %s" % (layer, pair)] - # Install routine is added as RUN commands - recipe += write_lines("RUN", self.recipe.install) + # Install routine is added as RUN commands + # TODO: this needs some work + recipe += write_lines("RUN", parser.install) - # Expose ports - recipe += write_lines("EXPOSE", self.recipe.ports) + # Expose ports + recipe += write_lines("EXPOSE", parser.ports) - if self.recipe.workdir is not None: - recipe.append("WORKDIR %s" % self.recipe.workdir) + if parser.workdir is not None: + recipe.append("WORKDIR %s" % parser.workdir) - # write the command, and entrypoint as is - if self.recipe.cmd is not None: - recipe.append("CMD %s" % self.recipe.cmd) + # write the command, and entrypoint as is + if parser.cmd is not None: + recipe.append("CMD %s" % parser.cmd) - if self.recipe.entrypoint is not None: - recipe.append("ENTRYPOINT %s" % self.recipe.entrypoint) + if parser.entrypoint is not None: + recipe.append("ENTRYPOINT %s" % parser.entrypoint) - if self.recipe.test is not None: - recipe += write_lines("HEALTHCHECK", self.recipe.test) + if parser.test is not None: + recipe += write_lines("HEALTHCHECK", parser.test) # Clean up extra white spaces recipe = "\n".join(recipe).replace("\n\n", "\n") diff --git a/spython/main/parse/writers/singularity.py b/spython/main/parse/writers/singularity.py index d75b6fa9..ae206e75 100644 --- a/spython/main/parse/writers/singularity.py +++ b/spython/main/parse/writers/singularity.py @@ -34,9 +34,6 @@ def validate(self): if self.recipe is None: bot.exit("Please provide a Recipe() to the writer first.") - if self.recipe.fromHeader is None: - bot.exit("Singularity recipe requires a from header.") - def convert(self, runscript="/bin/bash", force=False): """docker2singularity will return a Singularity build recipe based on a the loaded recipe object. It doesn't take any arguments as the @@ -45,31 +42,56 @@ def convert(self, runscript="/bin/bash", force=False): """ self.validate() - recipe = ["Bootstrap: docker"] - recipe += ["From: %s" % self.recipe.fromHeader] + # Write single recipe that includes all layer + recipe = [] + + # Number of layers + num_layers = len(self.recipe) + count = 0 + + # Write each layer to new file + for stage, parser in self.recipe.items(): + + # Set the first and active stage + self.stage = stage + + # From header is required + if parser.fromHeader is None: + bot.exit("Singularity recipe requires a from header.") - # Sections with key value pairs - recipe += self._create_section("files") - recipe += self._create_section("labels") - recipe += self._create_section("install", "post") - recipe += self._create_section("environ", "environment") + recipe += ["\n\n\nBootstrap: docker"] + recipe += ["From: %s" % parser.fromHeader] + recipe += ["Stage: %s\n\n\n" % stage] - # Take preference for user, entrypoint, command, then default - runscript = self._create_runscript(runscript, force) + # TODO: stopped here - bug with files being found + # Add global files, and then layer files + recipe += self._create_section("files") + for layer, files in parser.layer_files.items(): + recipe += create_keyval_section(files, "files", layer) - # If a working directory was used, add it as a cd - if self.recipe.workdir is not None: - runscript = ["cd " + self.recipe.workdir] + [runscript] + # Sections with key value pairs + recipe += self._create_section("labels") + recipe += self._create_section("install", "post") + recipe += self._create_section("environ", "environment") - # Finish the recipe, also add as startscript - recipe += finish_section(runscript, "runscript") - recipe += finish_section(runscript, "startscript") + # If we are at the last layer, write the runscript + if count == num_layers - 1: + runscript = self._create_runscript(runscript, force) - if self.recipe.test is not None: - recipe += finish_section(self.recipe.test, "test") + # If a working directory was used, add it as a cd + if parser.workdir is not None: + runscript = ["cd " + parser.workdir] + [runscript] + + # Finish the recipe, also add as startscript + recipe += finish_section(runscript, "runscript") + recipe += finish_section(runscript, "startscript") + + if parser.test is not None: + recipe += finish_section(parser.test, "test") + count += 1 # Clean up extra white spaces - recipe = "\n".join(recipe).replace("\n\n", "\n") + recipe = "\n".join(recipe).replace("\n\n", "\n").strip("\n") return recipe.rstrip() def _create_runscript(self, default="/bin/bash", force=False): @@ -89,19 +111,21 @@ def _create_runscript(self, default="/bin/bash", force=False): # Only look at Docker if not enforcing default if not force: - if self.recipe.entrypoint is not None: + if self.recipe[self.stage].entrypoint is not None: # The provided entrypoint can be a string or a list - if isinstance(self.recipe.entrypoint, list): - entrypoint = " ".join(self.recipe.entrypoint) + if isinstance(self.recipe[self.stage].entrypoint, list): + entrypoint = " ".join(self.recipe[self.stage].entrypoint) else: - entrypoint = "".join(self.recipe.entrypoint) + entrypoint = "".join(self.recipe[self.stage].entrypoint) - if self.recipe.cmd is not None: - if isinstance(self.recipe.cmd, list): - entrypoint = entrypoint + " " + " ".join(self.recipe.cmd) + if self.recipe[self.stage].cmd is not None: + if isinstance(self.recipe[self.stage].cmd, list): + entrypoint = ( + entrypoint + " " + " ".join(self.recipe[self.stage].cmd) + ) else: - entrypoint = entrypoint + " " + "".join(self.recipe.cmd) + entrypoint = entrypoint + " " + "".join(self.recipe[self.stage].cmd) # Entrypoint should use exec if not entrypoint.startswith("exec"): @@ -112,7 +136,7 @@ def _create_runscript(self, default="/bin/bash", force=False): entrypoint = '%s "$@"' % entrypoint return entrypoint - def _create_section(self, attribute, name=None): + def _create_section(self, attribute, name=None, stage=None): """create a section based on key, value recipe pairs, This is used for files or label @@ -133,7 +157,7 @@ def _create_section(self, attribute, name=None): # Only continue if we have the section and it's not empty try: - section = getattr(self.recipe, attribute) + section = getattr(self.recipe[self.stage], attribute) except AttributeError: bot.debug("Recipe does not have section for %s" % attribute) return section @@ -144,7 +168,7 @@ def _create_section(self, attribute, name=None): # Files if attribute in ["files", "labels"]: - return create_keyval_section(section, name) + return create_keyval_section(section, name, stage) # An environment section needs exports if attribute in ["environ"]: @@ -180,7 +204,7 @@ def finish_section(section, name): return header + lines -def create_keyval_section(pairs, name): +def create_keyval_section(pairs, name, layer): """create a section based on key, value recipe pairs, This is used for files or label @@ -188,9 +212,12 @@ def create_keyval_section(pairs, name): ========== section: the list of values to return as a parsed list of lines name: the name of the section to write (e.g., files) - + layer: if a layer name is provided, name section """ - section = ["%" + name] + if layer: + section = ["%" + name + " from %s" % layer] + else: + section = ["%" + name] for pair in pairs: section.append(" ".join(pair).strip().strip("\\")) return section diff --git a/spython/tests/test_conversion.py b/spython/tests/test_conversion.py index e84cf276..58da0b91 100644 --- a/spython/tests/test_conversion.py +++ b/spython/tests/test_conversion.py @@ -11,8 +11,9 @@ def read_file(file): - with open(file) as f: - return [line.rstrip("\n") for line in f] + with open(file) as fd: + content = fd.read().strip("\n") + return content def test_other_recipe_exists(test_data): @@ -42,8 +43,7 @@ def test_docker2singularity(test_data, tmp_path): for dockerfile, recipe in test_data["d2s"]: parser = DockerParser(dockerfile) writer = SingularityWriter(parser.recipe) - - assert writer.convert().split("\n") == read_file(recipe) + assert writer.convert().strip("\n") == read_file(recipe) def test_singularity2docker(test_data, tmp_path): @@ -55,5 +55,4 @@ def test_singularity2docker(test_data, tmp_path): for recipe, dockerfile in test_data["s2d"]: parser = SingularityParser(recipe) writer = DockerWriter(parser.recipe) - - assert writer.convert().split("\n") == read_file(dockerfile) + assert writer.convert() == read_file(dockerfile) diff --git a/spython/tests/test_parsers.py b/spython/tests/test_parsers.py index e9e5cc2c..15e4c7ce 100644 --- a/spython/tests/test_parsers.py +++ b/spython/tests/test_parsers.py @@ -28,31 +28,35 @@ def test_docker_parser(test_data): parser = DockerParser(dockerfile) assert str(parser) == "[spython-parser][docker]" + assert "spython-base" in parser.recipe + recipe = parser.recipe["spython-base"] # Test all fields from recipe - assert parser.recipe.fromHeader == "python:3.5.1" - assert parser.recipe.cmd == "/code/run_uwsgi.sh" - assert parser.recipe.entrypoint is None - assert parser.recipe.workdir == "/code" - assert parser.recipe.volumes == [] - assert parser.recipe.ports == ["3031"] - assert parser.recipe.files[0] == ["requirements.txt", "/tmp/requirements.txt"] - assert parser.recipe.environ == ["PYTHONUNBUFFERED=1"] - assert parser.recipe.source == dockerfile + assert recipe.fromHeader == "python:3.5.1" + assert recipe.cmd == "/code/run_uwsgi.sh" + assert recipe.entrypoint is None + assert recipe.workdir == "/code" + assert recipe.volumes == [] + assert recipe.ports == ["3031"] + assert recipe.files[0] == ["requirements.txt", "/tmp/requirements.txt"] + assert recipe.environ == ["PYTHONUNBUFFERED=1"] + assert recipe.source == dockerfile def test_singularity_parser(test_data): - recipe = os.path.join(test_data["root"], "Singularity") - parser = SingularityParser(recipe) + recipefile = os.path.join(test_data["root"], "Singularity") + parser = SingularityParser(recipefile) assert str(parser) == "[spython-parser][singularity]" + assert "spython-base" in parser.recipe + recipe = parser.recipe["spython-base"] # Test all fields from recipe - assert parser.recipe.fromHeader == "continuumio/miniconda3" - assert parser.recipe.cmd == 'exec /opt/conda/bin/spython "$@"' - assert parser.recipe.entrypoint is None - assert parser.recipe.workdir is None - assert parser.recipe.volumes == [] - assert parser.recipe.files == [] - assert parser.recipe.environ == [] - assert parser.recipe.source == recipe + assert recipe.fromHeader == "continuumio/miniconda3" + assert recipe.cmd == 'exec /opt/conda/bin/spython "$@"' + assert recipe.entrypoint is None + assert recipe.workdir is None + assert recipe.volumes == [] + assert recipe.files == [] + assert recipe.environ == [] + assert recipe.source == recipefile diff --git a/spython/tests/testdata/docker2singularity/add.def b/spython/tests/testdata/docker2singularity/add.def index 89a52c49..a73c6319 100644 --- a/spython/tests/testdata/docker2singularity/add.def +++ b/spython/tests/testdata/docker2singularity/add.def @@ -1,8 +1,10 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %files . /opt %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/cmd.def b/spython/tests/testdata/docker2singularity/cmd.def index d40c42a1..604f3f45 100644 --- a/spython/tests/testdata/docker2singularity/cmd.def +++ b/spython/tests/testdata/docker2singularity/cmd.def @@ -1,6 +1,8 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %runscript exec /bin/bash echo hello "$@" %startscript -exec /bin/bash echo hello "$@" \ No newline at end of file +exec /bin/bash echo hello "$@" diff --git a/spython/tests/testdata/docker2singularity/comments.def b/spython/tests/testdata/docker2singularity/comments.def index 6cedd2e1..5f49edef 100644 --- a/spython/tests/testdata/docker2singularity/comments.def +++ b/spython/tests/testdata/docker2singularity/comments.def @@ -1,5 +1,7 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %post # This is a really important line @@ -9,4 +11,4 @@ cp /bin/echo /opt/echo %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/copy.def b/spython/tests/testdata/docker2singularity/copy.def index 89a52c49..a73c6319 100644 --- a/spython/tests/testdata/docker2singularity/copy.def +++ b/spython/tests/testdata/docker2singularity/copy.def @@ -1,8 +1,10 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %files . /opt %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/entrypoint-cmd.def b/spython/tests/testdata/docker2singularity/entrypoint-cmd.def index d5a1a18d..3029268e 100644 --- a/spython/tests/testdata/docker2singularity/entrypoint-cmd.def +++ b/spython/tests/testdata/docker2singularity/entrypoint-cmd.def @@ -1,5 +1,7 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %runscript exec python /code/script.py "$@" %startscript diff --git a/spython/tests/testdata/docker2singularity/entrypoint.def b/spython/tests/testdata/docker2singularity/entrypoint.def index da3fba3a..bfe8965d 100644 --- a/spython/tests/testdata/docker2singularity/entrypoint.def +++ b/spython/tests/testdata/docker2singularity/entrypoint.def @@ -1,6 +1,8 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %runscript exec /bin/bash run_uwsgi.sh "$@" %startscript -exec /bin/bash run_uwsgi.sh "$@" \ No newline at end of file +exec /bin/bash run_uwsgi.sh "$@" diff --git a/spython/tests/testdata/docker2singularity/expose.def b/spython/tests/testdata/docker2singularity/expose.def index 74502b27..bed8064b 100644 --- a/spython/tests/testdata/docker2singularity/expose.def +++ b/spython/tests/testdata/docker2singularity/expose.def @@ -1,9 +1,11 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %post # EXPOSE 3031 # EXPOSE 9000 %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/from.def b/spython/tests/testdata/docker2singularity/from.def index b71937ec..79940ae1 100644 --- a/spython/tests/testdata/docker2singularity/from.def +++ b/spython/tests/testdata/docker2singularity/from.def @@ -1,6 +1,8 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/healthcheck.def b/spython/tests/testdata/docker2singularity/healthcheck.def index b2c0bd2a..9891d7f8 100644 --- a/spython/tests/testdata/docker2singularity/healthcheck.def +++ b/spython/tests/testdata/docker2singularity/healthcheck.def @@ -1,8 +1,10 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %runscript exec /bin/bash "$@" %startscript exec /bin/bash "$@" %test -true \ No newline at end of file +true diff --git a/spython/tests/testdata/docker2singularity/label.def b/spython/tests/testdata/docker2singularity/label.def index 6c8bb8fd..2728277a 100644 --- a/spython/tests/testdata/docker2singularity/label.def +++ b/spython/tests/testdata/docker2singularity/label.def @@ -1,8 +1,10 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %labels maintainer dinosaur %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/multiple-lines.def b/spython/tests/testdata/docker2singularity/multiple-lines.def index 02d6f86c..0af95244 100644 --- a/spython/tests/testdata/docker2singularity/multiple-lines.def +++ b/spython/tests/testdata/docker2singularity/multiple-lines.def @@ -1,5 +1,7 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %post apt-get update && \ @@ -10,4 +12,4 @@ squashfs-tools %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/multistage.def b/spython/tests/testdata/docker2singularity/multistage.def new file mode 100644 index 00000000..068ed5f9 --- /dev/null +++ b/spython/tests/testdata/docker2singularity/multistage.def @@ -0,0 +1,20 @@ +Bootstrap: docker +From: golang:1.12.3-alpine3.9 +Stage: devel + +%post +export PATH="/go/bin:/usr/local/go/bin:$PATH" +export HOME="/root" +cd /root +touch hello + +Bootstrap: docker +From: alpine:3.9 +Stage: final + +%files from devel +/root/hello /bin/hello +%runscript +exec /bin/bash "$@" +%startscript +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/multistage.docker b/spython/tests/testdata/docker2singularity/multistage.docker new file mode 100644 index 00000000..18b34ba1 --- /dev/null +++ b/spython/tests/testdata/docker2singularity/multistage.docker @@ -0,0 +1,7 @@ +FROM golang:1.12.3-alpine3.9 AS devel +RUN export PATH="/go/bin:/usr/local/go/bin:$PATH" +RUN export HOME="/root" +RUN cd /root +RUN touch hello +FROM alpine:3.9 AS final +COPY --from=devel /root/hello /bin/hello diff --git a/spython/tests/testdata/docker2singularity/user.def b/spython/tests/testdata/docker2singularity/user.def index c46871bc..b8695de4 100644 --- a/spython/tests/testdata/docker2singularity/user.def +++ b/spython/tests/testdata/docker2singularity/user.def @@ -1,5 +1,7 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %post echo "cloud" su - rainman # USER rainman @@ -8,4 +10,4 @@ su - root # USER root %runscript exec /bin/bash "$@" %startscript -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/docker2singularity/workdir.def b/spython/tests/testdata/docker2singularity/workdir.def index 7e1276f8..27b59414 100644 --- a/spython/tests/testdata/docker2singularity/workdir.def +++ b/spython/tests/testdata/docker2singularity/workdir.def @@ -1,5 +1,7 @@ Bootstrap: docker From: busybox:latest +Stage: spython-base + %post cd /code %runscript @@ -7,4 +9,4 @@ cd /code exec /bin/bash "$@" %startscript cd /code -exec /bin/bash "$@" \ No newline at end of file +exec /bin/bash "$@" diff --git a/spython/tests/testdata/singularity2docker/files.docker b/spython/tests/testdata/singularity2docker/files.docker index 920b21f9..425db97c 100644 --- a/spython/tests/testdata/singularity2docker/files.docker +++ b/spython/tests/testdata/singularity2docker/files.docker @@ -1,3 +1,3 @@ -FROM busybox:latest +FROM busybox:latest AS spython-base ADD file.txt /opt/file.txt -ADD /path/to/thing /opt/thing \ No newline at end of file +ADD /path/to/thing /opt/thing diff --git a/spython/tests/testdata/singularity2docker/from.docker b/spython/tests/testdata/singularity2docker/from.docker index f51439e3..8d8f9559 100644 --- a/spython/tests/testdata/singularity2docker/from.docker +++ b/spython/tests/testdata/singularity2docker/from.docker @@ -1 +1 @@ -FROM busybox:latest \ No newline at end of file +FROM busybox:latest AS spython-base diff --git a/spython/tests/testdata/singularity2docker/labels.docker b/spython/tests/testdata/singularity2docker/labels.docker index 3d126e35..158c4957 100644 --- a/spython/tests/testdata/singularity2docker/labels.docker +++ b/spython/tests/testdata/singularity2docker/labels.docker @@ -1,3 +1,3 @@ -FROM busybox:latest +FROM busybox:latest AS spython-base LABEL Maintainer dinosaur -LABEL Version 1.0.0 \ No newline at end of file +LABEL Version 1.0.0 diff --git a/spython/tests/testdata/singularity2docker/multiple-lines.docker b/spython/tests/testdata/singularity2docker/multiple-lines.docker index 75ccaa68..916b9e61 100644 --- a/spython/tests/testdata/singularity2docker/multiple-lines.docker +++ b/spython/tests/testdata/singularity2docker/multiple-lines.docker @@ -1,6 +1,6 @@ -FROM busybox:latest +FROM busybox:latest AS spython-base RUN apt-get update && \ apt-get install -y git \ wget \ curl \ -squashfs-tools \ No newline at end of file +squashfs-tools diff --git a/spython/tests/testdata/singularity2docker/multistage.def b/spython/tests/testdata/singularity2docker/multistage.def new file mode 100644 index 00000000..c85d5111 --- /dev/null +++ b/spython/tests/testdata/singularity2docker/multistage.def @@ -0,0 +1,19 @@ +Bootstrap: docker +From: golang:1.12.3-alpine3.9 +Stage: devel + +%post + # prep environment + export PATH="/go/bin:/usr/local/go/bin:$PATH" + export HOME="/root" + cd /root + touch hello + +# Install binary into final image +Bootstrap: docker +From: alpine:3.9 +Stage: final + +# install binary from stage one +%files from devel + /root/hello /bin/hello diff --git a/spython/tests/testdata/singularity2docker/multistage.docker b/spython/tests/testdata/singularity2docker/multistage.docker new file mode 100644 index 00000000..18b34ba1 --- /dev/null +++ b/spython/tests/testdata/singularity2docker/multistage.docker @@ -0,0 +1,7 @@ +FROM golang:1.12.3-alpine3.9 AS devel +RUN export PATH="/go/bin:/usr/local/go/bin:$PATH" +RUN export HOME="/root" +RUN cd /root +RUN touch hello +FROM alpine:3.9 AS final +COPY --from=devel /root/hello /bin/hello diff --git a/spython/tests/testdata/singularity2docker/post.docker b/spython/tests/testdata/singularity2docker/post.docker index 6872d43e..95ae6453 100644 --- a/spython/tests/testdata/singularity2docker/post.docker +++ b/spython/tests/testdata/singularity2docker/post.docker @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:latest AS spython-base RUN apt-get update RUN apt-get install -y git \ -wget \ No newline at end of file +wget diff --git a/spython/tests/testdata/singularity2docker/runscript.docker b/spython/tests/testdata/singularity2docker/runscript.docker index 94fd139c..ac85d450 100644 --- a/spython/tests/testdata/singularity2docker/runscript.docker +++ b/spython/tests/testdata/singularity2docker/runscript.docker @@ -1,2 +1,2 @@ -FROM busybox:latest -CMD exec /bin/bash echo hello "$@" \ No newline at end of file +FROM busybox:latest AS spython-base +CMD exec /bin/bash echo hello "$@" diff --git a/spython/tests/testdata/singularity2docker/test.docker b/spython/tests/testdata/singularity2docker/test.docker index cbd12cc9..2e4b6955 100644 --- a/spython/tests/testdata/singularity2docker/test.docker +++ b/spython/tests/testdata/singularity2docker/test.docker @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:latest AS spython-base RUN echo "true" >> /tests.sh RUN chmod u+x /tests.sh -HEALTHCHECK /bin/bash /tests.sh \ No newline at end of file +HEALTHCHECK /bin/bash /tests.sh diff --git a/spython/version.py b/spython/version.py index 730fb7d9..2fdb0858 100644 --- a/spython/version.py +++ b/spython/version.py @@ -5,7 +5,7 @@ # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -__version__ = "0.0.82" +__version__ = "0.0.83" AUTHOR = "Vanessa Sochat" AUTHOR_EMAIL = "vsochat@stanford.edu" NAME = "spython" From 6a8868c9ade396b058d05e66e6e212b65fed6510 Mon Sep 17 00:00:00 2001 From: vsoch Date: Sat, 30 May 2020 16:31:36 -0600 Subject: [PATCH 2/4] updating docs Signed-off-by: vsoch --- docs/pages/recipes.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/pages/recipes.md b/docs/pages/recipes.md index 753aa2ee..d49b463b 100644 --- a/docs/pages/recipes.md +++ b/docs/pages/recipes.md @@ -22,8 +22,7 @@ Now we can answer what kind of things might you want to do: - convert a Singularity Recipe to a Dockerfile - read in a recipe of either type, and modify it before doing the above -**Important** Singularity does not support multistage builds defined within -a single file, so if your Dockerfile has lines like: +**Important** Singularity Python added support for parsing [multistage builds](https://sylabs.io/guides/3.5/user-guide/definition_files.html#multi-stage-builds) for version 0.0.83 and after. ``` COPY --from=builder /build/usr/share/gdal/ /usr/share/gdal/ From 546efb375872bd761e8d5526261d00db60c92b89 Mon Sep 17 00:00:00 2001 From: vsoch Date: Sat, 30 May 2020 16:32:13 -0600 Subject: [PATCH 3/4] updating docs Signed-off-by: vsoch --- docs/pages/recipes.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/pages/recipes.md b/docs/pages/recipes.md index d49b463b..aa1ea134 100644 --- a/docs/pages/recipes.md +++ b/docs/pages/recipes.md @@ -22,12 +22,10 @@ Now we can answer what kind of things might you want to do: - convert a Singularity Recipe to a Dockerfile - read in a recipe of either type, and modify it before doing the above -**Important** Singularity Python added support for parsing [multistage builds](https://sylabs.io/guides/3.5/user-guide/definition_files.html#multi-stage-builds) for version 0.0.83 and after. +**Important** Singularity Python added support for parsing [multistage builds](https://sylabs.io/guides/3.5/user-guide/definition_files.html#multi-stage-builds) for version 0.0.83 and after. By default, +any base layer that isn't named is called `spython-base` unless you have named +it otherwise. -``` -COPY --from=builder /build/usr/share/gdal/ /usr/share/gdal/ -``` -You should modify the Dockerfile first to remove them. # Command Line Client From 3efbf9e225c5ab762461b71b7666af6a9b0bbe6e Mon Sep 17 00:00:00 2001 From: vsoch Date: Sat, 30 May 2020 17:32:13 -0600 Subject: [PATCH 4/4] also update documentation to use recipes Signed-off-by: vsoch --- docs/pages/recipes.md | 99 ++++++++++++++++++++++++++---------- spython/main/parse/recipe.py | 3 ++ 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/docs/pages/recipes.md b/docs/pages/recipes.md index aa1ea134..d8cac8eb 100644 --- a/docs/pages/recipes.md +++ b/docs/pages/recipes.md @@ -35,7 +35,7 @@ for quick visual inspection or piping into an output file. If you use the `spython` utility, you can see your options available: -``` +```bash spython recipe --help usage: spython recipe [-h] [--entrypoint ENTRYPOINT] [--json] [--force] @@ -112,7 +112,7 @@ $ spython recipe --parser singularity container.def Another customization to a recipe can be modifying the entrypoint on the fly. -``` +```bash $ spython recipe --entrypoint /bin/sh Dockerfile ... %runscript @@ -154,7 +154,7 @@ technology) you can do that. ```python from spython.main.parse.recipe import Recipe -recipe = Recipe +recipe = Recipe() ``` By default, the recipe starts empty. @@ -179,6 +179,7 @@ recipe.labels = ['Maintainer vanessasaur'] recipe.ports = ['3031'] recipe.volumes = ['/data'] recipe.workdir = '/code' +recipe.fromHeader = 'ubuntu:18:04' ``` And then verify they are added: @@ -199,6 +200,24 @@ recipe.json() ``` And then you can use a [writer](#writer) to print a custom recipe type to file. +Note that the writer is intended for multistage builds, meaning that the recipe +you provide it should be a lookup with sections. For example: + +``` +from spython.main.parse.writers import DockerWriter +writer = DockerWriter({"baselayer": recipe}) + +FROM ubuntu:18:04 AS baselayer +ADD one two +LABEL Maintainer vanessasaur +ENV PANCAKES=WITHSYRUP +RUN apt-get update +EXPOSE 3031 +WORKDIR /code +CMD ['echo', 'hello'] +ENTRYPOINT /bin/bash +HEALTHCHECK true +``` # Parsers @@ -224,42 +243,66 @@ then give it a Dockerfile to munch on. parser=DockerParser('Dockerfile') ``` -By default, it will parse the Dockerfile (or other container recipe) into a `Recipe` -class, provided at `parser.recipe`: +By default, it will parse the Dockerfile (or other container recipe) into a lookup of `Recipe` +class, each of which is a layer / stage for the build. ```python -parser.recipe -[spython-recipe][source:/home/vanessa/Documents/Dropbox/Code/sregistry/singularity-cli/Dockerfile] +> parser.recipe +{'builder': [spython-recipe][source:/home/vanessa/Desktop/Code/singularity-cli/Dockerfile], + 'runner': [spython-recipe][source:/home/vanessa/Desktop/Code/singularity-cli/Dockerfile]} ``` -You can quickly see the fields with the .json function: +In the above, we see that the Dockerfile has two staged, the first named `builder` +and the second named `runner`. You can inspect each of these recipes by indexing into +the dictionary. E.g., here is how to look at the .json output as we did previously: ```python -parser.recipe.json() -{'cmd': '/code/run_uwsgi.sh', - 'environ': ['PYTHONUNBUFFERED=1'], - 'files': [['requirements.txt', '/tmp/requirements.txt'], - ['/home/vanessa/Documents/Dropbox/Code/sregistry/singularity-cli', - '/code/']], - 'install': ['PYTHONUNBUFFERED=1', -... -``` - +parser.recipe['runner'].json() +Out[6]: +{'fromHeader': 'ubuntu:20.04 ', + 'layer_files': {'builder': [['/build_thirdparty/usr/', '/usr/'], + ['/build${PROJ_INSTALL_PREFIX}/share/proj/', + '${PROJ_INSTALL_PREFIX}/share/proj/'], + ['/build${PROJ_INSTALL_PREFIX}/include/', + '${PROJ_INSTALL_PREFIX}/include/'], + ['/build${PROJ_INSTALL_PREFIX}/bin/', '${PROJ_INSTALL_PREFIX}/bin/'], + ['/build${PROJ_INSTALL_PREFIX}/lib/', '${PROJ_INSTALL_PREFIX}/lib/'], + ['/build/usr/share/gdal/', '/usr/share/gdal/'], + ['/build/usr/include/', '/usr/include/'], + ['/build_gdal_python/usr/', '/usr/'], + ['/build_gdal_version_changing/usr/', '/usr/']]}, + 'install': ['\n', + 'date', + '\n', + '# PROJ dependencies\n', + 'apt-get update; \\', + 'DEBIAN_FRONTEND=noninteractive apt-get install -y \\', + ... + '\n', + 'ldconfig']} +``` + +Notice in the above that we have a section called `layer_files` that a writer knows +how to parse into a `%files` section from the previous layer. All of these fields are attributes of the recipe, so you could change or otherwise -interact with them: +interact with them. For example, here we are adding an entrypoint. ```python -parser.recipe.entrypoint = '/bin/sh' +parser.recipe['runner'].entrypoint = '/bin/sh' ``` -or if you don't want to, you can skip automatic parsing: +or if you don't want to, you can skip automatic parsing. Here we inspect a single, +empty recipe layer: ```python -parser = DockerParser('Dockerfile', load=False) -parser.recipe.json() +parser = DockerParser('Dockerfile', load=False) parser.recipe +{'spython-base': [spython-recipe][source:/home/vanessa/Desktop/Code/singularity-cli/Dockerfile]} + +parser.recipe['spython-base'].json() +{} ``` -And then parse it later: +WHen you are ready to parse it (to show the layers we saw previously) ```python parser.parse() @@ -272,9 +315,10 @@ SingularityParser = get_parser("Singularity") parser = SingularityParser("Singularity") ``` ```python -parser.recipe.json() -Out[16]: +parser.recipe['spython-base'].json() +Out[21]: {'cmd': 'exec /opt/conda/bin/spython "$@"', + 'fromHeader': 'continuumio/miniconda3', 'install': ['apt-get update && apt-get install -y git', '# Dependencies', 'cd /opt', @@ -283,7 +327,6 @@ Out[16]: '/opt/conda/bin/pip install setuptools', '/opt/conda/bin/python setup.py install'], 'labels': ['maintainer vsochat@stanford.edu']} - ``` # Writers @@ -356,7 +399,7 @@ writer = DockerWriter(parser.recipe) print(writer.convert()) ``` ``` -FROM continuumio/miniconda3 +FROM continuumio/miniconda3 AS spython-base LABEL maintainer vsochat@stanford.edu RUN apt-get update && apt-get install -y git RUN cd /opt diff --git a/spython/main/parse/recipe.py b/spython/main/parse/recipe.py index a367a847..0fcb476d 100644 --- a/spython/main/parse/recipe.py +++ b/spython/main/parse/recipe.py @@ -36,6 +36,7 @@ def __init__(self, recipe=None, layer=1): self.volumes = [] self.workdir = None self.layer = layer + self.fromHeader = None self.source = recipe @@ -66,6 +67,8 @@ def json(self): "entrypoint", "environ", "files", + "fromHeader", + "layer_files", "install", "labels", "ports",