debian-forge/osbuild/objectstore.py
Christian Kellner be8aafbb90 objectstore: Object.read() for read only access
Provide a way to read the current contents of the object, in a way
the follows the copy-on-write semantics: If `base` is set but the
object has not yet been written to, the `base` content will be
exposed. If no base is set or the object has been written to, the
current (temporary) tree will be exposed. In either way it is done
via a bind mount so it is assured that the contents indeed can only
be read from, but not written to.
The code also currently make sure that there is no write operation
started as long as there is at least one reader.
Additionally, also introduce checks that the object is intact, i.e.
not cleaned up, for all operations that require such a state.
2020-02-28 16:11:49 +01:00

291 lines
9.1 KiB
Python

import contextlib
import errno
import hashlib
import os
import subprocess
import tempfile
from typing import Optional
from . import treesum
__all__ = [
"ObjectStore",
]
@contextlib.contextmanager
def suppress_oserror(*errnos):
"""A context manager that suppresses any OSError with an errno in `errnos`.
Like contextlib.suppress, but can differentiate between OSErrors.
"""
try:
yield
except OSError as e:
if e.errno not in errnos:
raise e
def mount(source, target, bind=True, ro=True, private=True, mode="0755"):
options = []
if bind:
options += ["bind"]
if ro:
options += ["ro"]
if mode:
options += [mode]
args = []
if private:
args += ["--make-private"]
if options:
args += ["-o", ",".join(options)]
subprocess.run(["mount"] + args + [source, target], check=True)
def umount(target, lazy=True):
args = []
if lazy:
args += ["--lazy"]
subprocess.run(["umount"] + args + [target], check=True)
class Object:
def __init__(self, store: "ObjectStore"):
self._init = True
self._readers = 0
self._base = None
self._workdir = None
self._tree = None
self.store = store
self.reset()
def init(self) -> None:
"""Initialize the object with content of its base"""
self._check_writable()
self._check_readers()
if self._init:
return
source = self.store.resolve_ref(self._base)
subprocess.run(["cp", "--reflink=auto", "-a",
f"{source}/.", self._tree],
check=True)
self._init = True
@property
def base(self) -> Optional[str]:
return self._base
@base.setter
def base(self, base_id: Optional[str]):
self._init = not base_id
self._base = base_id
@property
def treesum(self) -> str:
"""Calculate the treesum of the object"""
with self._open() as fd:
m = hashlib.sha256()
treesum.treesum(m, fd)
treesum_hash = m.hexdigest()
return treesum_hash
@property
def _path(self) -> str:
if self._base and not self._init:
path = self.store.resolve_ref(self._base)
else:
path = self._tree
return path
def write(self) -> str:
"""Return a path that can be written to"""
self._check_writable()
self._check_readers()
self.init()
return self._tree
@contextlib.contextmanager
def read(self) -> str:
self._check_writable()
with self.tempdir("mount") as target:
mount(self._path, target)
try:
self._readers += 1
yield target
finally:
umount(target)
self._readers -= 1
def store_tree(self, destination: str):
"""Store the tree at destination and reset itself
Moves the tree atomically by using rename(2). If the
target already exist, does nothing. Afterwards it
resets itself and can be used as if it was new.
"""
self._check_writable()
self._check_readers()
self.init()
with suppress_oserror(errno.ENOTEMPTY, errno.EEXIST):
os.rename(self._tree, destination)
self.reset()
def reset(self):
self.cleanup()
self._workdir = self.store.tempdir(suffix="object")
self._tree = os.path.join(self._workdir.name, "tree")
os.makedirs(self._tree, mode=0o755, exist_ok=True)
self._init = not self._base
def cleanup(self):
self._check_readers()
if self._workdir:
self._workdir.cleanup()
self._workdir = None
def _check_readers(self):
"""Internal: Raise a ValueError if there are readers"""
if self._readers:
raise ValueError("Read operation is ongoing")
def _check_writable(self):
"""Internal: Raise a ValueError if not writable"""
if not self._workdir:
raise ValueError("Object is not writable")
@contextlib.contextmanager
def _open(self):
"""Open the directory and return the file descriptor"""
with self.read() as path:
fd = os.open(path, os.O_DIRECTORY)
try:
yield fd
finally:
os.close(fd)
def tempdir(self, suffix=None):
workdir = self._workdir.name
if suffix:
suffix = "-" + suffix
return tempfile.TemporaryDirectory(dir=workdir,
suffix=suffix)
def __enter__(self):
self._check_writable()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return exc_type is None
class ObjectStore:
def __init__(self, store):
self.store = store
self.objects = f"{store}/objects"
self.refs = f"{store}/refs"
os.makedirs(self.store, exist_ok=True)
os.makedirs(self.objects, exist_ok=True)
os.makedirs(self.refs, exist_ok=True)
def contains(self, object_id):
if not object_id:
return False
return os.access(self.resolve_ref(object_id), os.F_OK)
def resolve_ref(self, object_id: Optional[str]) -> Optional[str]:
"""Returns the path to the given object_id"""
if not object_id:
return None
return f"{self.refs}/{object_id}"
def tempdir(self, prefix=None, suffix=None):
"""Return a tempfile.TemporaryDirectory within the store"""
return tempfile.TemporaryDirectory(dir=self.store,
prefix=prefix,
suffix=suffix)
@contextlib.contextmanager
def get(self, object_id):
with self.tempdir() as tmp:
if object_id:
path = self.resolve_ref(object_id)
mount(path, tmp)
try:
yield tmp
finally:
umount(tmp)
else:
# None was given as object_id, just return an empty directory
yield tmp
@contextlib.contextmanager
def new(self, object_id, base_id=None):
"""Creates a new `Object` for `object_id`.
This method must be used as a context manager. It returns a new
temporary instance of `Object`. It will only be committed to the
store if the context completes without raising an exception.
"""
with Object(self) as obj:
# the object that is yielded will be added to the content store
# on success as object_id
if base_id:
# if we were given a base id then this is the base for the
# new object
# NB: its initialization is deferred to the first write
obj.base = base_id
yield obj
# if the yield above raises an exception, the working tree
# is cleaned up by tempfile, otherwise, the it the content
# of it was created or modified by the caller. All that is
# left to do is to commit it to the object store
self.commit(obj, object_id)
def commit(self, obj: Object, object_id: str) -> str:
"""Commits a Object to the object store
Move the contents of the obj (Object) to object directory
of the store with the content hash (obj.treesum) as its name.
Creates a symlink to that ('objects/{hash}') in the references
directory with the object_id as the name ('refs/{object_id}).
If the link already exists, it will be atomically replaced.
Returns: The treesum of the object
"""
treesum_hash = obj.treesum
# the object is stored in the objects directory using its content
# hash as its name, ideally a given object_id (i.e., given config)
# will always produce the same content hash, but that is not
# guaranteed. If an object with the same treesum already exist, us
# the existing one instead
obj.store_tree(f"{self.objects}/{treesum_hash}")
# symlink the object_id (config hash) in the refs directory to the
# treesum (content hash) in the objects directory. If a symlink by
# that name alreday exists, atomically replace it, but leave the
# backing object in place (it may be in use).
with self.tempdir() as tmp:
link = f"{tmp}/link"
os.symlink(f"../objects/{treesum_hash}", link)
os.replace(link, self.resolve_ref(object_id))
# the reference that is pointing to `treesum_hash` is now the base
# of `obj`. It is not actively initialized but any subsequent calls
# to `obj.write()` will initialize it again
# NB: in the case that an object with the same treesum as `obj`
# already existed in the store obj.store_tree() will not actually
# have written anything to the store. In this case `obj` will then
# be initialized with the content of the already existing object.
obj.base = object_id
return treesum_hash