#!/usr/bin/env python3 # # Copyright (C) 2017-2018 Codethink Limited # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see . # # Authors: # Jürg Billeter import multiprocessing import os import signal import string import tempfile from .. import _ostree, _signals, utils from .._exceptions import ArtifactError from ..element import _KeyStrength from .._ostree import OSTreeError from . import ArtifactCache from .pushreceive import initialize_push_connection from .pushreceive import push as push_artifact from .pushreceive import PushException def buildref(element, key): project = element._get_project() # Normalize ostree ref unsupported chars valid_chars = string.digits + string.ascii_letters + '-._' element_name = ''.join([ x if x in valid_chars else '_' for x in element.normal_name ]) if key is None: raise ArtifactError('Cache key missing') # assume project and element names are not allowed to contain slashes return '{0}/{1}/{2}'.format(project.name, element_name, key) # An OSTreeCache manages artifacts in an OSTree repository # # Args: # context (Context): The BuildStream context # project (Project): The BuildStream project # enable_push (bool): Whether pushing is allowed by the platform # # Pushing is explicitly disabled by the platform in some cases, # like when we are falling back to functioning without using # user namespaces. # class OSTreeCache(ArtifactCache): def __init__(self, context, *, enable_push): super().__init__(context) self.enable_push = enable_push ostreedir = os.path.join(context.artifactdir, 'ostree') self.repo = _ostree.ensure(ostreedir, False) self.push_urls = {} self.pull_urls = {} self._remote_refs = {} self._has_fetch_remotes = False self._has_push_remotes = False def has_fetch_remotes(self): return self._has_fetch_remotes def has_push_remotes(self, *, element=None): if not self._has_push_remotes: # No project has push remotes return False elif element is None: # At least one (sub)project has push remotes return True else: # Check whether the specified element's project has push remotes return len(self.push_urls[element._get_project()]) > 0 # contains(): # # Check whether the artifact for the specified Element is already available # in the local artifact cache. # # Args: # element (Element): The Element to check # strength (_KeyStrength): Either STRONG or WEAK key strength, or None # # Returns: True if the artifact is in the cache, False otherwise # def contains(self, element, strength=None): if strength is None: strength = _KeyStrength.STRONG if element._get_strict() else _KeyStrength.WEAK if strength == _KeyStrength.STRONG: key = element._get_strict_cache_key() else: key = element._get_cache_key(strength) if not key: return False ref = buildref(element, key) return _ostree.exists(self.repo, ref) # remote_contains_key(): # # Check whether the artifact for the specified Element is already available # in the remote artifact cache. # # Args: # element (Element): The Element to check # key (str): The key to use # # Returns: True if the artifact is in the cache, False otherwise # def remote_contains_key(self, element, key): if not self._has_fetch_remotes: return False remote_refs = self._remote_refs[element._get_project()] if len(remote_refs) == 0: return False ref = buildref(element, key) return ref in remote_refs # remote_contains(): # # Check whether the artifact for the specified Element is already available # in the remote artifact cache. # # Args: # element (Element): The Element to check # strength (_KeyStrength): Either STRONG or WEAK key strength, or None # # Returns: True if the artifact is in the cache, False otherwise # def remote_contains(self, element, strength=None): if strength is None: strength = _KeyStrength.STRONG if element._get_strict() else _KeyStrength.WEAK if strength == _KeyStrength.STRONG: key = element._get_strict_cache_key() else: key = element._get_cache_key(strength) if not key: return False return self.remote_contains_key(element, key) # extract(): # # Extract cached artifact for the specified Element if it hasn't # already been extracted. # # Assumes artifact has previously been fetched or committed. # # Args: # element (Element): The Element to extract # # Raises: # ArtifactError: In cases there was an OSError, or if the artifact # did not exist. # # Returns: path to extracted artifact # def extract(self, element): ref = buildref(element, element._get_strict_cache_key()) # resolve ref to checksum rev = _ostree.checksum(self.repo, ref) # resolve weak cache key, if artifact is missing for strong cache key # and the context allows use of weak cache keys if not rev and not element._get_strict(): ref = buildref(element, element._get_cache_key(strength=_KeyStrength.WEAK)) rev = _ostree.checksum(self.repo, ref) if not rev: raise ArtifactError("Artifact missing for {}".format(ref)) dest = os.path.join(self.extractdir, element._get_project().name, element.normal_name, rev) if os.path.isdir(dest): # artifact has already been extracted return dest os.makedirs(self.extractdir, exist_ok=True) with tempfile.TemporaryDirectory(prefix='tmp', dir=self.extractdir) as tmpdir: checkoutdir = os.path.join(tmpdir, ref) _ostree.checkout(self.repo, checkoutdir, rev, user=True) os.makedirs(os.path.dirname(dest), exist_ok=True) try: os.rename(checkoutdir, dest) except OSError as e: # With rename, it's possible to get either ENOTEMPTY or EEXIST # in the case that the destination path is a not empty directory. # # If rename fails with these errors, another process beat # us to it so just ignore. if e.errno not in [os.errno.ENOTEMPTY, os.errno.EEXIST]: raise ArtifactError("Failed to extract artifact for ref '{}': {}" .format(ref, e)) from e return dest # commit(): # # Commit built artifact to cache. # # Args: # element (Element): The Element commit an artifact for # content (str): The element's content directory # def commit(self, element, content): # tag with strong cache key based on dependency versions used for the build ref = buildref(element, element._get_cache_key()) # also store under weak cache key weak_ref = buildref(element, element._get_cache_key(strength=_KeyStrength.WEAK)) try: _ostree.commit(self.repo, content, ref, weak_ref) except OSTreeError as e: raise ArtifactError("Failed to commit artifact: {}".format(e)) from e # pull(): # # Pull artifact from one of the configured remote repositories. # # Args: # element (Element): The Element whose artifact is to be fetched # progress (callable): The progress callback, if any # def pull(self, element, progress=None): project = element._get_project() remote_refs = self._remote_refs[project] ref = buildref(element, element._get_strict_cache_key()) weak_ref = buildref(element, element._get_cache_key(strength=_KeyStrength.WEAK)) try: if ref in remote_refs: # fetch the artifact using the strong cache key _ostree.fetch(self.repo, remote=remote_refs[ref], ref=ref, progress=progress) # resolve ref to checksum rev = _ostree.checksum(self.repo, ref) # update weak ref by pointing it to this newly fetched artifact _ostree.set_ref(self.repo, weak_ref, rev) elif weak_ref in remote_refs: # fetch the artifact using the weak cache key _ostree.fetch(self.repo, remote=remote_refs[weak_ref], ref=weak_ref, progress=progress) # resolve weak_ref to checksum rev = _ostree.checksum(self.repo, weak_ref) # extract strong cache key from this newly fetched artifact element._update_state() ref = buildref(element, element._get_cache_key()) # create tag for strong cache key _ostree.set_ref(self.repo, ref, rev) else: raise ArtifactError("Attempt to pull unavailable artifact for element {}" .format(element.name)) except OSTreeError as e: raise ArtifactError("Failed to pull artifact for element {}: {}" .format(element.name, e)) from e # push(): # # Push committed artifact to remote repository. # # Args: # element (Element): The Element whose artifact is to be pushed # # Returns: # (bool): True if the remote was updated, False if it already existed # and no updated was required # # Raises: # (ArtifactError): if there was an error def push(self, element): any_pushed = False project = element._get_project() push_urls = self.push_urls[project] if len(push_urls) == 0: raise ArtifactError("Push is not enabled for any of the configured remote artifact caches.") ref = buildref(element, element._get_cache_key()) weak_ref = buildref(element, element._get_cache_key(strength=_KeyStrength.WEAK)) for push_url in push_urls: any_pushed |= self._push_to_remote(push_url, element, ref, weak_ref) return any_pushed # _initialize_remote(): # # Do protocol-specific initialization necessary to use a given OSTree # remote. # # The SSH protocol that we use only supports pushing so initializing these # involves contacting the remote to find out the corresponding pull URL. # # Args: # url (str): URL of the remote # # Returns: # (str, str): the pull URL and push URL for the remote # # Raises: # ArtifactError: if there was an error def _initialize_remote(self, url): if url.startswith('ssh://'): try: push_url = url pull_url = initialize_push_connection(url) except PushException as e: raise ArtifactError(e) from e elif url.startswith('/'): push_url = pull_url = 'file://' + url elif url.startswith('file://'): push_url = pull_url = url elif url.startswith('http://') or url.startswith('https://'): push_url = None pull_url = url else: raise ArtifactError("Unsupported URL: {}".format(url)) return push_url, pull_url # _ensure_remote(): # # Ensure that our OSTree repo has a remote configured for the given URL. # Note that SSH access to remotes is not handled by libostree itself. # # Args: # repo (OSTree.Repo): an OSTree repository # pull_url (str): the URL where libostree can pull from the remote # # Returns: # (str): the name of the remote, which can be passed to various other # operations implemented by the _ostree module. # # Raises: # OSTreeError: if there was a problem reported by libostree def _ensure_remote(self, repo, pull_url): remote_name = utils.url_directory_name(pull_url) _ostree.configure_remote(repo, remote_name, pull_url) return remote_name def initialize_remotes(self, *, on_failure=None): remote_specs = self.global_remote_specs for project in self.project_remote_specs: remote_specs += self.project_remote_specs[project] remote_specs = list(utils._deduplicate(remote_specs)) remote_results = {} # Callback to initialize one remote in a 'multiprocessing' subprocess. # # We cannot do this in the main process because of the way the tasks # run by the main scheduler calls into libostree using # fork()-without-exec() subprocesses. OSTree fetch operations in # subprocesses hang if fetch operations were previously done in the # main process. # def child_action(url, q): try: push_url, pull_url = self._initialize_remote(url) remote = self._ensure_remote(self.repo, pull_url) remote_refs = _ostree.list_remote_refs(self.repo, remote=remote) q.put((None, push_url, pull_url, remote_refs)) except Exception as e: q.put((str(e), None, None, None)) # Kick off all the initialization jobs one by one. # # Note that we cannot use multiprocessing.Pool here because it's not # possible to pickle local functions such as child_action(). # q = multiprocessing.Queue() for remote in remote_specs: p = multiprocessing.Process(target=child_action, args=(remote.url, q)) try: # Keep SIGINT blocked in the child process with _signals.blocked([signal.SIGINT], ignore=False): p.start() error, push_url, pull_url, remote_refs = q.get() p.join() except KeyboardInterrupt: utils._kill_process_tree(p.pid) raise if error and on_failure: on_failure(remote.url, error) elif error: raise ArtifactError(error) else: if remote.push and push_url: self._has_push_remotes = True if pull_url: self._has_fetch_remotes = True remote_results[remote.url] = (push_url, pull_url, remote_refs) # Prepare push_urls, pull_urls, and remote_refs for each project for project in self.context._get_projects(): remote_specs = self.global_remote_specs if project in self.project_remote_specs: remote_specs = list(utils._deduplicate(remote_specs + self.project_remote_specs[project])) push_urls = [] pull_urls = [] _remote_refs = {} for remote in remote_specs: # Errors are already handled in the loop above, # skip unreachable remotes here. if remote.url not in remote_results: continue push_url, pull_url, remote_refs = remote_results[remote.url] if remote.push: if push_url: push_urls.append(push_url) else: raise ArtifactError("Push enabled but not supported by repo at: {}".format(remote.url)) # The specs are deduplicated when reading the config, but since # each push URL can supply an arbitrary pull URL we must dedup # those again here. if pull_url and pull_url not in pull_urls: pull_urls.append(pull_url) # Update our overall map of remote refs with any refs that are # present in the new remote and were not already found in # higher priority ones. remote = self._ensure_remote(self.repo, pull_url) for ref in remote_refs: if ref not in _remote_refs: _remote_refs[ref] = remote self.push_urls[project] = push_urls self.pull_urls[project] = pull_urls self._remote_refs[project] = _remote_refs def _push_to_remote(self, push_url, element, ref, weak_ref): with utils._tempdir(dir=self.context.artifactdir, prefix='push-repo-') as temp_repo_dir: with element.timed_activity("Preparing compressed archive"): # First create a temporary archive-z2 repository, we can # only use ostree-push with archive-z2 local repo. temp_repo = _ostree.ensure(temp_repo_dir, True) # Now push the ref we want to push into our temporary archive-z2 repo _ostree.fetch(temp_repo, remote=self.repo.get_path().get_uri(), ref=ref) _ostree.fetch(temp_repo, remote=self.repo.get_path().get_uri(), ref=weak_ref) with element.timed_activity("Sending artifact"), \ element._output_file() as output_file: try: pushed = push_artifact(temp_repo.get_path().get_path(), push_url, [ref, weak_ref], output_file) except PushException as e: raise ArtifactError("Failed to push artifact {}: {}".format(ref, e)) from e return pushed