Jim MacArthur pushed to branch jmac/vdir_import_test at BuildStream / buildstream
Commits:
- 
68ef69e4
by Tristan Van Berkom at 2018-09-21T05:20:46Z
- 
662c729f
by Tristan Van Berkom at 2018-09-21T05:59:30Z
- 
461a0588
by Jim MacArthur at 2018-09-21T10:53:11Z
- 
aa9caaac
by Jim MacArthur at 2018-09-21T10:53:11Z
- 
2aae68c7
by Jim MacArthur at 2018-09-21T10:53:11Z
- 
ca1bb72c
by Jim MacArthur at 2018-09-21T10:53:11Z
- 
55c93a82
by Jim MacArthur at 2018-09-21T11:26:55Z
- 
42168657
by Jim MacArthur at 2018-09-21T13:27:04Z
- 
d673cdf8
by Jim MacArthur at 2018-09-21T13:27:04Z
7 changed files:
- buildstream/_artifactcache/cascache.py
- buildstream/element.py
- buildstream/sandbox/_sandboxremote.py
- buildstream/source.py
- buildstream/storage/__init__.py
- tests/artifactcache/push.py
- + tests/storage/virtual_directory_import.py
Changes:
| ... | ... | @@ -348,19 +348,29 @@ class CASCache(ArtifactCache): | 
| 348 | 348 |          return pushed
 | 
| 349 | 349 |  | 
| 350 | 350 |      def push_directory(self, project, directory):
 | 
| 351 | +        """ Push the given virtual directory to all remotes.
 | |
| 352 | + | |
| 353 | +        Args:
 | |
| 354 | +            project (Project): The current project
 | |
| 355 | +            directory (Directory): A virtual directory object to push.
 | |
| 356 | + | |
| 357 | +        Raises: ArtifactError if no push remotes are configured.
 | |
| 358 | +        """
 | |
| 351 | 359 |  | 
| 352 | 360 |          push_remotes = [r for r in self._remotes[project] if r.spec.push]
 | 
| 353 | 361 |  | 
| 362 | +        if not push_remotes:
 | |
| 363 | +            raise ArtifactError("CASCache: push_directory was called, but no remote artifact " +
 | |
| 364 | +                                "servers are configured as push remotes.")
 | |
| 365 | + | |
| 354 | 366 |          if directory.ref is None:
 | 
| 355 | -            return None
 | |
| 367 | +            return
 | |
| 356 | 368 |  | 
| 357 | 369 |          for remote in push_remotes:
 | 
| 358 | 370 |              remote.init()
 | 
| 359 | 371 |  | 
| 360 | 372 |              self._send_directory(remote, directory.ref)
 | 
| 361 | 373 |  | 
| 362 | -        return directory.ref
 | |
| 363 | - | |
| 364 | 374 |      def push_message(self, project, message):
 | 
| 365 | 375 |  | 
| 366 | 376 |          push_remotes = [r for r in self._remotes[project] if r.spec.push]
 | 
| ... | ... | @@ -2137,14 +2137,11 @@ class Element(Plugin): | 
| 2137 | 2137 |          project = self._get_project()
 | 
| 2138 | 2138 |          platform = Platform.get_platform()
 | 
| 2139 | 2139 |  | 
| 2140 | -        if self.__remote_execution_url and self.BST_VIRTUAL_DIRECTORY:
 | |
| 2141 | -            if not self.__artifacts.has_push_remotes(element=self):
 | |
| 2142 | -                # Give an early warning if remote execution will not work
 | |
| 2143 | -                raise ElementError("Artifact {} is configured to use remote execution but has no push remotes. "
 | |
| 2144 | -                                   .format(self.name) +
 | |
| 2145 | -                                   "The remote artifact server(s) may not be correctly configured or contactable.")
 | |
| 2146 | - | |
| 2147 | -            self.info("Using a remote sandbox for artifact {}".format(self.name))
 | |
| 2140 | +        if (directory is not None and
 | |
| 2141 | +            self.__remote_execution_url and
 | |
| 2142 | +            self.BST_VIRTUAL_DIRECTORY):
 | |
| 2143 | + | |
| 2144 | +            self.info("Using a remote sandbox for artifact {} with directory '{}'".format(self.name, directory))
 | |
| 2148 | 2145 |  | 
| 2149 | 2146 |              sandbox = SandboxRemote(context, project,
 | 
| 2150 | 2147 |                                      directory,
 | 
| ... | ... | @@ -173,8 +173,8 @@ class SandboxRemote(Sandbox): | 
| 173 | 173 |          platform = Platform.get_platform()
 | 
| 174 | 174 |          cascache = platform.artifactcache
 | 
| 175 | 175 |          # Now, push that key (without necessarily needing a ref) to the remote.
 | 
| 176 | -        vdir_digest = cascache.push_directory(self._get_project(), upload_vdir)
 | |
| 177 | -        if not vdir_digest or not cascache.verify_digest_pushed(self._get_project(), vdir_digest):
 | |
| 176 | +        cascache.push_directory(self._get_project(), upload_vdir)
 | |
| 177 | +        if not cascache.verify_digest_pushed(self._get_project(), upload_vdir.ref):
 | |
| 178 | 178 |              raise SandboxError("Failed to verify that source has been pushed to the remote artifact cache.")
 | 
| 179 | 179 |  | 
| 180 | 180 |          # Set up environment and working directory
 | 
| ... | ... | @@ -930,6 +930,38 @@ class Source(Plugin): | 
| 930 | 930 |      #                   Local Private Methods                   #
 | 
| 931 | 931 |      #############################################################
 | 
| 932 | 932 |  | 
| 933 | +    # __clone_for_uri()
 | |
| 934 | +    #
 | |
| 935 | +    # Clone the source with an alternative URI setup for the alias
 | |
| 936 | +    # which this source uses.
 | |
| 937 | +    #
 | |
| 938 | +    # This is used for iteration over source mirrors.
 | |
| 939 | +    #
 | |
| 940 | +    # Args:
 | |
| 941 | +    #    uri (str): The alternative URI for this source's alias
 | |
| 942 | +    #
 | |
| 943 | +    # Returns:
 | |
| 944 | +    #    (Source): A new clone of this Source, with the specified URI
 | |
| 945 | +    #              as the value of the alias this Source has marked as
 | |
| 946 | +    #              primary with either mark_download_url() or
 | |
| 947 | +    #              translate_url().
 | |
| 948 | +    #
 | |
| 949 | +    def __clone_for_uri(self, uri):
 | |
| 950 | +        project = self._get_project()
 | |
| 951 | +        context = self._get_context()
 | |
| 952 | +        alias = self._get_alias()
 | |
| 953 | +        source_kind = type(self)
 | |
| 954 | + | |
| 955 | +        clone = source_kind(context, project, self.__meta, alias_override=(alias, uri))
 | |
| 956 | + | |
| 957 | +        # Do the necessary post instantiation routines here
 | |
| 958 | +        #
 | |
| 959 | +        clone._preflight()
 | |
| 960 | +        clone._load_ref()
 | |
| 961 | +        clone._update_state()
 | |
| 962 | + | |
| 963 | +        return clone
 | |
| 964 | + | |
| 933 | 965 |      # Tries to call fetch for every mirror, stopping once it succeeds
 | 
| 934 | 966 |      def __do_fetch(self, **kwargs):
 | 
| 935 | 967 |          project = self._get_project()
 | 
| ... | ... | @@ -968,12 +1000,8 @@ class Source(Plugin): | 
| 968 | 1000 |                  self.fetch(**kwargs)
 | 
| 969 | 1001 |                  return
 | 
| 970 | 1002 |  | 
| 971 | -            context = self._get_context()
 | |
| 972 | -            source_kind = type(self)
 | |
| 973 | 1003 |              for uri in project.get_alias_uris(alias, first_pass=self.__first_pass):
 | 
| 974 | -                new_source = source_kind(context, project, self.__meta,
 | |
| 975 | -                                         alias_override=(alias, uri))
 | |
| 976 | -                new_source._preflight()
 | |
| 1004 | +                new_source = self.__clone_for_uri(uri)
 | |
| 977 | 1005 |                  try:
 | 
| 978 | 1006 |                      new_source.fetch(**kwargs)
 | 
| 979 | 1007 |                  # FIXME: Need to consider temporary vs. permanent failures,
 | 
| ... | ... | @@ -1006,9 +1034,7 @@ class Source(Plugin): | 
| 1006 | 1034 |          # NOTE: We are assuming here that tracking only requires substituting the
 | 
| 1007 | 1035 |          #       first alias used
 | 
| 1008 | 1036 |          for uri in reversed(project.get_alias_uris(alias, first_pass=self.__first_pass)):
 | 
| 1009 | -            new_source = source_kind(context, project, self.__meta,
 | |
| 1010 | -                                     alias_override=(alias, uri))
 | |
| 1011 | -            new_source._preflight()
 | |
| 1037 | +            new_source = self.__clone_for_uri(uri)
 | |
| 1012 | 1038 |              try:
 | 
| 1013 | 1039 |                  ref = new_source.track(**kwargs)
 | 
| 1014 | 1040 |              # FIXME: Need to consider temporary vs. permanent failures,
 | 
| ... | ... | @@ -19,4 +19,5 @@ | 
| 19 | 19 |  #        Jim MacArthur <jim macarthur codethink co uk>
 | 
| 20 | 20 |  | 
| 21 | 21 |  from ._filebaseddirectory import FileBasedDirectory
 | 
| 22 | +from ._casbaseddirectory import CasBasedDirectory
 | |
| 22 | 23 |  from .directory import Directory | 
| ... | ... | @@ -228,9 +228,9 @@ def _test_push_directory(user_config_file, project_dir, artifact_dir, artifact_d | 
| 228 | 228 |          directory = CasBasedDirectory(context, ref=artifact_digest)
 | 
| 229 | 229 |  | 
| 230 | 230 |          # Push the CasBasedDirectory object
 | 
| 231 | -        directory_digest = cas.push_directory(project, directory)
 | |
| 231 | +        cas.push_directory(project, directory)
 | |
| 232 | 232 |  | 
| 233 | -        queue.put(directory_digest.hash)
 | |
| 233 | +        queue.put(directory.ref.hash)
 | |
| 234 | 234 |      else:
 | 
| 235 | 235 |          queue.put("No remote configured")
 | 
| 236 | 236 |  | 
| 1 | +import os
 | |
| 2 | +import pytest
 | |
| 3 | +from tests.testutils import cli
 | |
| 4 | + | |
| 5 | +from buildstream.storage import CasBasedDirectory
 | |
| 6 | + | |
| 7 | + | |
| 8 | +class FakeContext():
 | |
| 9 | +    def __init__(self):
 | |
| 10 | +        self.config_cache_quota = "65536"
 | |
| 11 | + | |
| 12 | +    def get_projects(self):
 | |
| 13 | +        return []
 | |
| 14 | + | |
| 15 | +# This is a set of example file system contents. The test attempts to import
 | |
| 16 | +# each on top of each other to test importing works consistently.
 | |
| 17 | +# Each tuple is defined as (<filename>, <type>, <content>). Type can be
 | |
| 18 | +# 'F' (file), 'S' (symlink) or 'D' (directory) with content being the contents
 | |
| 19 | +# for a file or the destination for a symlink.
 | |
| 20 | +root_filesets = [
 | |
| 21 | +    [('a/b/c/textfile1', 'F', 'This is textfile 1\n')],
 | |
| 22 | +    [('a/b/c/textfile1', 'F', 'This is the replacement textfile 1\n')],
 | |
| 23 | +    [('a/b/d', 'D', '')],
 | |
| 24 | +    [('a/b/c', 'S', '/a/b/d')],
 | |
| 25 | +    [('a/b/d', 'D', ''), ('a/b/c', 'S', '/a/b/d')],
 | |
| 26 | +]
 | |
| 27 | + | |
| 28 | +empty_hash_ref = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
 | |
| 29 | + | |
| 30 | + | |
| 31 | +def generate_import_roots(directory):
 | |
| 32 | +    for fileset in range(1, len(root_filesets) + 1):
 | |
| 33 | +        rootname = "root{}".format(fileset)
 | |
| 34 | +        rootdir = os.path.join(directory, "content", rootname)
 | |
| 35 | + | |
| 36 | +        for (path, typesymbol, content) in root_filesets[fileset - 1]:
 | |
| 37 | +            if typesymbol == 'F':
 | |
| 38 | +                (dirnames, filename) = os.path.split(path)
 | |
| 39 | +                os.makedirs(os.path.join(rootdir, dirnames), exist_ok=True)
 | |
| 40 | +                with open(os.path.join(rootdir, dirnames, filename), "wt") as f:
 | |
| 41 | +                    f.write(content)
 | |
| 42 | +            elif typesymbol == 'D':
 | |
| 43 | +                os.makedirs(os.path.join(rootdir, path), exist_ok=True)
 | |
| 44 | +            elif typesymbol == 'S':
 | |
| 45 | +                (dirnames, filename) = os.path.split(path)
 | |
| 46 | +                os.makedirs(os.path.join(rootdir, dirnames), exist_ok=True)
 | |
| 47 | +                os.symlink(content, os.path.join(rootdir, path))
 | |
| 48 | + | |
| 49 | + | |
| 50 | +def file_contents(path):
 | |
| 51 | +    with open(path, "r") as f:
 | |
| 52 | +        result = f.read()
 | |
| 53 | +    return result
 | |
| 54 | + | |
| 55 | + | |
| 56 | +def file_contents_are(path, contents):
 | |
| 57 | +    return file_contents(path) == contents
 | |
| 58 | + | |
| 59 | + | |
| 60 | +def create_new_vdir(root_number, fake_context, tmpdir):
 | |
| 61 | +    d = CasBasedDirectory(fake_context)
 | |
| 62 | +    d.import_files(os.path.join(tmpdir, "content", "root{}".format(root_number)))
 | |
| 63 | +    assert d.ref.hash != empty_hash_ref
 | |
| 64 | +    return d
 | |
| 65 | + | |
| 66 | + | |
| 67 | +def combinations(integer_range):
 | |
| 68 | +    for x in integer_range:
 | |
| 69 | +        for y in integer_range:
 | |
| 70 | +            yield (x, y)
 | |
| 71 | + | |
| 72 | + | |
| 73 | +def resolve_symlinks(path, root):
 | |
| 74 | +    """ A function to resolve symlinks inside 'path' components apart from the last one.
 | |
| 75 | +        For example, resolve_symlinks('/a/b/c/d', '/a/b')
 | |
| 76 | +        will return '/a/b/f/d' if /a/b/c is a symlink to /a/b/f. The final component of
 | |
| 77 | +        'path' is not resolved, because we typically want to inspect the symlink found
 | |
| 78 | +        at that path, not its target.
 | |
| 79 | + | |
| 80 | +    """
 | |
| 81 | +    components = path.split(os.path.sep)
 | |
| 82 | +    location = root
 | |
| 83 | +    for i in range(0, len(components) - 1):
 | |
| 84 | +        location = os.path.join(location, components[i])
 | |
| 85 | +        if os.path.islink(location):
 | |
| 86 | +            # Resolve the link, add on all the remaining components
 | |
| 87 | +            target = os.path.join(os.readlink(location))
 | |
| 88 | +            tail = os.path.sep.join(components[i + 1:])
 | |
| 89 | + | |
| 90 | +            if target.startswith(os.path.sep):
 | |
| 91 | +                # Absolute link - relative to root
 | |
| 92 | +                location = os.path.join(root, target, tail)
 | |
| 93 | +            else:
 | |
| 94 | +                # Relative link - relative to symlink location
 | |
| 95 | +                location = os.path.join(location, target)
 | |
| 96 | +            return resolve_symlinks(location, root)
 | |
| 97 | +    # If we got here, no symlinks were found. Add on the final component and return.
 | |
| 98 | +    location = os.path.join(location, components[-1])
 | |
| 99 | +    return location
 | |
| 100 | + | |
| 101 | + | |
| 102 | +def directory_not_empty(path):
 | |
| 103 | +    return os.listdir(path)
 | |
| 104 | + | |
| 105 | + | |
| 106 | +@pytest.mark.parametrize("original,overlay", combinations([1, 2, 3, 4, 5]))
 | |
| 107 | +def test_cas_import(cli, tmpdir, original, overlay):
 | |
| 108 | +    tmpdir = str(tmpdir)  # Avoids LocalPath issues on some systems
 | |
| 109 | +    fake_context = FakeContext()
 | |
| 110 | +    fake_context.artifactdir = tmpdir
 | |
| 111 | +    # Create some fake content
 | |
| 112 | +    generate_import_roots(tmpdir)
 | |
| 113 | + | |
| 114 | +    d = create_new_vdir(original, fake_context, tmpdir)
 | |
| 115 | +    d2 = create_new_vdir(overlay, fake_context, tmpdir)
 | |
| 116 | +    d.import_files(d2)
 | |
| 117 | +    d.export_files(os.path.join(tmpdir, "output"))
 | |
| 118 | + | |
| 119 | +    for item in root_filesets[overlay - 1]:
 | |
| 120 | +        (path, typename, content) = item
 | |
| 121 | +        realpath = resolve_symlinks(path, os.path.join(tmpdir, "output"))
 | |
| 122 | +        if typename == 'F':
 | |
| 123 | +            if os.path.isdir(realpath) and directory_not_empty(realpath):
 | |
| 124 | +                # The file should not have overwritten the directory in this case.
 | |
| 125 | +                pass
 | |
| 126 | +            else:
 | |
| 127 | +                assert os.path.isfile(realpath), "{} did not exist in the combined virtual directory".format(path)
 | |
| 128 | +                assert file_contents_are(realpath, content)
 | |
| 129 | +        elif typename == 'S':
 | |
| 130 | +            if os.path.isdir(realpath) and directory_not_empty(realpath):
 | |
| 131 | +                # The symlink should not have overwritten the directory in this case.
 | |
| 132 | +                pass
 | |
| 133 | +            else:
 | |
| 134 | +                assert os.path.islink(realpath)
 | |
| 135 | +                assert os.readlink(realpath) == content
 | |
| 136 | +        elif typename == 'D':
 | |
| 137 | +            # Note that isdir accepts symlinks to dirs, so a symlink to a dir is acceptable.
 | |
| 138 | +            assert os.path.isdir(realpath) | 
