blob: 238c6b4dbf197fc35ccb1a92a9e275a4881ca2fd [file] [log] [blame]
#
# Copyright (C) 2017 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 <http://www.gnu.org/licenses/>.
#
# Authors:
# Jürg Billeter <juerg.billeter@codethink.co.uk>
# Andrew Leeming <andrew.leeming@codethink.co.uk>
# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
#
# Code based on Jürg's artifact cache and Andrew's ostree plugin
#
# Disable pylint warnings that are not appicable to this module
# pylint: disable=bad-exception-context,catching-non-exception
import os
from collections import namedtuple
import gi
from gi.repository.GLib import Variant, VariantDict
from ._exceptions import BstError, ErrorDomain
# pylint: disable=wrong-import-position,wrong-import-order
gi.require_version('OSTree', '1.0')
from gi.repository import GLib, Gio, OSTree # nopep8
# For users of this file, they must expect (except) it.
class OSTreeError(BstError):
def __init__(self, message, reason=None):
super().__init__(message, domain=ErrorDomain.UTIL, reason=reason)
# ensure()
#
# Args:
# path (str): The file path to where the desired repo should be
# compress (bool): use compression or not when creating
#
# Returns: an OSTree.Repo
def ensure(path, compress):
# create also succeeds on existing repository
repo = OSTree.Repo.new(Gio.File.new_for_path(path))
mode = OSTree.RepoMode.ARCHIVE_Z2 if compress \
else OSTree.RepoMode.BARE_USER
repo.create(mode)
# Disble OSTree's built in minimum-disk-space check.
config = repo.copy_config()
config.set_string('core', 'min-free-space-percent', '0')
repo.write_config(config)
repo.reload_config()
return repo
# checkout()
#
# Checkout the content at 'commit' from 'repo' in
# the specified 'path'
#
# Args:
# repo (OSTree.Repo): The repo
# path (str): The checkout path
# commit_ (str): The commit checksum to checkout
# user (boot): Whether to checkout in user mode
#
def checkout(repo, path, commit_, user=False):
# Check out a full copy of an OSTree at a given ref to some directory.
#
# Note: OSTree does not like updating directories inline/sync, therefore
# make sure you checkout to a clean directory or add additional code to support
# union mode or (if it exists) file replacement/update.
#
# Returns True on success
#
# cli exmaple:
# ostree --repo=repo checkout --user-mode runtime/org.freedesktop.Sdk/x86_64/1.4 foo
os.makedirs(os.path.dirname(path), exist_ok=True)
options = OSTree.RepoCheckoutAtOptions()
# For repos which contain root owned files, we need
# to checkout with OSTree.RepoCheckoutMode.USER
#
# This will reassign uid/gid and also munge the
# permission bits a bit.
if user:
options.mode = OSTree.RepoCheckoutMode.USER
# Using AT_FDCWD value from fcntl.h
#
# This will be ignored if the passed path is an absolute path,
# if path is a relative path then it will be appended to the
# current working directory.
AT_FDCWD = -100
try:
repo.checkout_at(options, AT_FDCWD, path, commit_)
except GLib.GError as e:
raise OSTreeError("Failed to checkout commit '{}': {}".format(commit_, e.message)) from e
# commit():
#
# Commit built artifact to cache.
#
# Files are all recorded with uid/gid 0
#
# Args:
# repo (OSTree.Repo): The repo
# dir_ (str): The source directory to commit to the repo
# refs (list): A list of symbolic references (tag) for the commit
#
def commit(repo, dir_, refs):
def commit_filter(repo, path, file_info):
# For now, just set everything in the repo as uid/gid 0
#
# In the future we'll want to extract virtualized file
# attributes from a fuse layer and use that.
#
file_info.set_attribute_uint32('unix::uid', 0)
file_info.set_attribute_uint32('unix::gid', 0)
return OSTree.RepoCommitFilterResult.ALLOW
commit_modifier = OSTree.RepoCommitModifier.new(
OSTree.RepoCommitModifierFlags.NONE, commit_filter)
repo.prepare_transaction()
try:
# add tree to repository
mtree = OSTree.MutableTree.new()
repo.write_directory_to_mtree(Gio.File.new_for_path(dir_),
mtree, commit_modifier)
_, root = repo.write_mtree(mtree)
# create root commit object, no parent, no branch
_, rev = repo.write_commit(None, None, None, None, root)
# create refs
for ref in refs:
repo.transaction_set_ref(None, ref, rev)
# complete repo transaction
repo.commit_transaction(None)
except GLib.GError as e:
# Reraise any error as a buildstream error
repo.abort_transaction()
raise OSTreeError(e.message) from e
# set_ref():
#
# Set symbolic reference to specified revision.
#
# Args:
# repo (OSTree.Repo): The repo
# ref (str): A symbolic reference (tag) for the commit
# rev (str): Commit checksum
#
def set_ref(repo, ref, rev):
repo.prepare_transaction()
try:
repo.transaction_set_ref(None, ref, rev)
# complete repo transaction
repo.commit_transaction(None)
except:
repo.abort_transaction()
raise
# exists():
#
# Checks wether a given commit or symbolic ref exists and
# is locally cached in the specified repo.
#
# Args:
# repo (OSTree.Repo): The repo
# ref (str): A commit checksum or symbolic ref
#
# Returns:
# (bool): Whether 'ref' is valid in 'repo'
#
def exists(repo, ref):
# Get the commit checksum, this will:
#
# o Return a commit checksum if ref is a symbolic branch
# o Return the same commit checksum if ref is a valid commit checksum
# o Return None if the ostree repo doesnt know this ref.
#
ref = checksum(repo, ref)
if ref is None:
return False
# If we do have a ref which the ostree knows about, this does
# not mean we necessarily have the object locally (we may just
# have some metadata about it, this can happen).
#
# Use has_object() only with a resolved valid commit checksum
# to check if we actually have the object locally.
_, has_object = repo.has_object(OSTree.ObjectType.COMMIT, ref, None)
return has_object
# remove():
#
# Removes the given commit or symbolic ref from the repo.
#
# Args:
# repo (OSTree.Repo): The repo
# ref (str): A commit checksum or symbolic ref
# defer_prune (bool): Whether to defer pruning to the caller. NOTE:
# The space won't be freed until you manually
# call repo.prune.
#
# Returns:
# (int|None) The amount of space pruned from the repository in
# Bytes, or None if defer_prune is True
#
def remove(repo, ref, *, defer_prune=False):
# Get the commit checksum, this will:
#
# o Return a commit checksum if ref is a symbolic branch
# o Return the same commit checksum if ref is a valid commit checksum
# o Return None if the ostree repo doesnt know this ref.
#
check = checksum(repo, ref)
if check is None:
raise OSTreeError("Could not find artifact for ref '{}'".format(ref))
repo.set_ref_immediate(None, ref, None)
if not defer_prune:
_, _, _, pruned = repo.prune(OSTree.RepoPruneFlags.REFS_ONLY, -1)
return pruned
return None
# checksum():
#
# Returns the commit checksum for a given symbolic ref,
# which might be a branch or tag. If it is a branch,
# the latest commit checksum for the given branch is returned.
#
# Args:
# repo (OSTree.Repo): The repo
# ref (str): The symbolic ref
#
# Returns:
# (str): The commit checksum, or None if ref does not exist.
#
def checksum(repo, ref):
_, checksum_ = repo.resolve_rev(ref, True)
return checksum_
OSTREE_GIO_FAST_QUERYINFO = ("standard::name,standard::type,standard::size,"
"standard::is-symlink,standard::symlink-target,"
"unix::device,unix::inode,unix::mode,unix::uid,"
"unix::gid,unix::rdev")
DiffItem = namedtuple('DiffItem', ['src', 'src_info',
'target', 'target_info',
'src_checksum', 'target_checksum'])
# diff_dirs():
#
# Compute the difference between directory a and b as 3 separate sets
# of OSTree.DiffItem.
#
# This is more-or-less a direct port of OSTree.diff_dirs (which cannot
# be used via PyGobject), but does not support options.
#
# Args:
# a (Gio.File): The first directory for the comparison.
# b (Gio.File): The second directory for the comparison.
#
# Returns:
# (modified, removed, added)
#
def diff_dirs(a, b):
# get_file_checksum():
#
# Helper to compute the checksum of an arbitrary file (different
# objects have different methods to compute these).
#
def get_file_checksum(f, f_info):
if isinstance(f, OSTree.RepoFile):
return f.get_checksum()
else:
contents = None
if f_info.get_file_type() == Gio.FileType.REGULAR:
contents = f.read()
csum = OSTree.checksum_file_from_input(f_info, None, contents,
OSTree.ObjectType.FILE)
return OSTree.checksum_from_bytes(csum)
# diff_files():
#
# Helper to compute a diff between two files.
#
def diff_files(a, a_info, b, b_info):
checksum_a = get_file_checksum(a, a_info)
checksum_b = get_file_checksum(b, b_info)
if checksum_a != checksum_b:
return DiffItem(a, a_info, b, b_info, checksum_a, checksum_b)
return None
# diff_add_dir_recurse():
#
# Helper to collect all files in a directory recursively.
#
def diff_add_dir_recurse(d):
added = []
dir_enum = d.enumerate_children(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
for child_info in dir_enum:
name = child_info.get_name()
child = d.get_child(name)
added.append(child)
if child_info.get_file_type() == Gio.FileType.DIRECTORY:
added.extend(diff_add_dir_recurse(child))
return added
modified = []
removed = []
added = []
child_a_info = a.query_info(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
child_b_info = b.query_info(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
# If both are directories and have the same checksum, we know that
# none of the underlying files changed, so we can save time.
if (child_a_info.get_file_type() == Gio.FileType.DIRECTORY and
child_b_info.get_file_type() == Gio.FileType.DIRECTORY and
isinstance(a, OSTree.RepoFileClass) and
isinstance(b, OSTree.RepoFileClass)):
if a.tree_get_contents_checksum() == b.tree_get_contents_checksum():
return modified, removed, added
# We walk through 'a' first
dir_enum = a.enumerate_children(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
for child_a_info in dir_enum:
name = child_a_info.get_name()
child_a = a.get_child(name)
child_a_type = child_a_info.get_file_type()
try:
child_b = b.get_child(name)
child_b_info = child_b.query_info(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
except GLib.Error as e:
# If the file does not exist in b, it has been removed
if e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
removed.append(child_a)
continue
else:
raise
# If the files differ but are of different types, we report a
# modification, saving a bit of time because we won't need a
# checksum
child_b_type = child_b_info.get_file_type()
if child_a_type != child_b_type:
diff_item = DiffItem(child_a, child_a_info,
child_b, child_b_info,
None, None)
modified.append(diff_item)
# Finally, we compute checksums and compare the file contents directly
else:
diff_item = diff_files(child_a, child_a_info, child_b, child_b_info)
if diff_item:
modified.append(diff_item)
# If the files are both directories, we recursively use
# this function to find differences - saving time if they
# are equal.
if child_a_type == Gio.FileType.DIRECTORY:
subdir = diff_dirs(child_a, child_b)
modified.extend(subdir[0])
removed.extend(subdir[1])
added.extend(subdir[2])
# Now we walk through 'b' to find any files that were added
dir_enum = b.enumerate_children(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
for child_b_info in dir_enum:
name = child_b_info.get_name()
child_b = b.get_child(name)
try:
child_a = a.get_child(name)
child_a_info = child_a.query_info(OSTREE_GIO_FAST_QUERYINFO,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
except GLib.Error as e:
# If the file does not exist in 'a', it was added.
if e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND):
added.append(child_b)
if child_b_info.get_file_type() == Gio.FileType.DIRECTORY:
added.extend(diff_add_dir_recurse(child_b))
continue
else:
raise
return modified, removed, added
# fetch()
#
# Fetch new objects from a remote, if configured
#
# Args:
# repo (OSTree.Repo): The repo
# remote (str): An optional remote name, defaults to 'origin'
# ref (str): An optional ref to fetch, will reduce the amount of objects fetched
# progress (callable): An optional progress callback
#
# Note that a commit checksum or a branch reference are both
# valid options for the 'ref' parameter. Using the ref parameter
# can save a lot of bandwidth but mirroring the full repo is
# still possible.
#
def fetch(repo, remote="origin", ref=None, progress=None):
# Fetch metadata of the repo from a remote
#
# cli example:
# ostree --repo=repo pull --mirror freedesktop:runtime/org.freedesktop.Sdk/x86_64/1.4
def progress_callback(info):
status = async_progress.get_status()
outstanding_fetches = async_progress.get_uint('outstanding-fetches')
bytes_transferred = async_progress.get_uint64('bytes-transferred')
fetched = async_progress.get_uint('fetched')
requested = async_progress.get_uint('requested')
if status:
progress(0.0, status)
elif outstanding_fetches > 0:
formatted_bytes = GLib.format_size_full(bytes_transferred, 0)
if requested == 0:
percent = 0.0
else:
percent = (fetched * 1.0 / requested) * 100
progress(percent,
"Receiving objects: {:d}% ({:d}/{:d}) {}".format(int(percent), fetched,
requested, formatted_bytes))
else:
progress(100.0, "Writing Objects")
async_progress = None
if progress is not None:
async_progress = OSTree.AsyncProgress.new()
async_progress.connect('changed', progress_callback)
# FIXME: This hangs the process and ignores keyboard interrupt,
# fix this using the Gio.Cancellable
refs = None
if ref is not None:
refs = [ref]
try:
repo.pull(remote,
refs,
OSTree.RepoPullFlags.MIRROR,
async_progress,
None) # Gio.Cancellable
except GLib.GError as e:
if ref is not None:
raise OSTreeError("Failed to fetch ref '{}' from '{}': {}".format(ref, remote, e.message)) from e
else:
raise OSTreeError("Failed to fetch from '{}': {}".format(remote, e.message)) from e
# configure_remote():
#
# Ensures a remote is setup to a given url.
#
# Args:
# repo (OSTree.Repo): The repo
# remote (str): The name of the remote
# url (str): The url of the remote ostree repo
# key_url (str): The optional url of a GPG key (should be a local file)
#
def configure_remote(repo, remote, url, key_url=None):
# Add a remote OSTree repo. If no key is given, we disable gpg checking.
#
# cli exmaple:
# wget https://sdk.gnome.org/keys/gnome-sdk.gpg
# ostree --repo=repo --gpg-import=gnome-sdk.gpg remote add freedesktop https://sdk.gnome.org/repo
options = None # or GLib.Variant of type a{sv}
if key_url is None:
vd = VariantDict.new()
vd.insert_value('gpg-verify', Variant.new_boolean(False))
options = vd.end()
try:
repo.remote_change(None, # Optional OSTree.Sysroot
OSTree.RepoRemoteChange.ADD_IF_NOT_EXISTS,
remote, # Remote name
url, # Remote url
options, # Remote options
None) # Optional Gio.Cancellable
except GLib.GError as e:
raise OSTreeError("Failed to configure remote '{}': {}".format(remote, e.message)) from e
# Remote needs to exist before adding key
if key_url is not None:
try:
gfile = Gio.File.new_for_uri(key_url)
stream = gfile.read()
repo.remote_gpg_import(remote, stream, None, 0, None)
except GLib.GError as e:
raise OSTreeError("Failed to add gpg key from url '{}': {}".format(key_url, e.message)) from e
# list_artifacts():
#
# List cached artifacts in Least Recently Modified (LRM) order.
#
# Returns:
# (list) - A list of refs in LRM order
#
def list_artifacts(repo):
# string of: /path/to/repo/refs/heads
ref_heads = os.path.join(repo.get_path().get_path(), 'refs', 'heads')
# obtain list of <project>/<element>/<key>
refs = _list_all_refs(repo).keys()
mtimes = []
for ref in refs:
ref_path = os.path.join(ref_heads, ref)
if os.path.exists(ref_path):
# Obtain the mtime (the time a file was last modified)
mtimes.append(os.path.getmtime(ref_path))
# NOTE: Sorted will sort from earliest to latest, thus the
# first element of this list will be the file modified earliest.
return [ref for _, ref in sorted(zip(mtimes, refs))]
# _list_all_refs():
#
# Create a list of all refs.
#
# Args:
# repo (OSTree.Repo): The repo
#
# Returns:
# (dict): A dict of refs to checksums.
#
def _list_all_refs(repo):
try:
_, refs = repo.list_refs(None)
return refs
except GLib.GError as e:
raise OSTreeError(message=e.message) from e