test: add new osbuild executor

Add a new OSBuild class to `./test/test.py`. This class is an extension
of `./test/osbuildtest.py`, but no longer requires the `output_id` and
`tree_id` identifiers of osbuild.

Furthermore, this new executor uses context-managers to make sure any
temporary object is only accessed for a contained time-frame.
This commit is contained in:
David Rheinsberg 2020-05-20 11:10:22 +02:00
parent 9dfa0e8a61
commit 20beabf431

View file

@ -2,11 +2,15 @@
# Test Infrastructure # Test Infrastructure
# #
import contextlib
import errno import errno
import json
import os import os
import subprocess import subprocess
import sys
import tempfile import tempfile
import osbuild
from osbuild.util import linux from osbuild.util import linux
@ -131,3 +135,175 @@ class TestBase():
return False return False
return r.returncode == 0 and "compose" in r.stdout return r.returncode == 0 and "compose" in r.stdout
class OSBuild(contextlib.AbstractContextManager):
"""OSBuild Executor
This class represents a context to execute osbuild. It provides a context
manager, which while entered maintains a cache and output directory. This
allows running pipelines against a common setup and tear everything down
when exiting.
"""
_unittest = None
_cache_from = None
_exitstack = None
_cachedir = None
_outputdir = None
def __init__(self, unittest, cache_from=None):
self._unittest = unittest
self._cache_from = cache_from
def __enter__(self):
self._exitstack = contextlib.ExitStack()
with self._exitstack:
# Create a temporary cache-directory. Optionally initialize it from
# the cache specified by the caller.
# Support for `cache_from` should be dropped once our cache allows
# parallel writes. For now, this allows initializing test-runs with
# a prepopulated cache for faster testing.
cache = tempfile.TemporaryDirectory(dir="/var/tmp")
self._cachedir = self._exitstack.enter_context(cache)
if self._cache_from is not None:
subprocess.run(["cp", "--reflink=auto", "-a",
os.path.join(self._cache_from, "."),
self._cachedir],
check=True)
# Create a temporary output-directors for assembled artifacts.
output = tempfile.TemporaryDirectory(dir="/var/tmp")
self._outputdir = self._exitstack.enter_context(output)
# Keep our ExitStack for `__exit__()`.
self._exitstack = self._exitstack.pop_all()
return self
def __exit__(self, exc_type, exc_value, exc_tb):
# Clean up our ExitStack.
with self._exitstack:
pass
self._outputdir = None
self._cachedir = None
self._exitstack = None
def _print_result(self, code, data_stdout, data_stderr):
print(f"osbuild failed with: {code}")
try:
json_stdout = json.loads(data_stdout)
print("-- STDOUT (json) -----------------------")
json.dump(json_stdout, sys.stdout, indent=2)
except json.JSONDecodeError:
print("-- STDOUT (raw) ------------------------")
print(data_stdout)
print("-- STDERR ------------------------------")
print(data_stderr)
print("-- END ---------------------------------")
def compile(self, data_stdin, checkpoints=[]):
"""Compile an Artifact
This takes a manifest as `data_stdin`, executes the pipeline, and
assembles the artifact. No intermediate steps are kept, unless you
provide suitable checkpoints.
The produced artifact (if any) is stored in the output directory. Use
`map_output()` to temporarily map the file and get access. Note that
the output directory becomes invalid when you leave the context-manager
of this class.
"""
cmd_args = []
cmd_args += ["--json"]
cmd_args += ["--libdir", "."]
cmd_args += ["--output-directory", self._outputdir]
cmd_args += ["--store", self._cachedir]
for c in checkpoints:
cmd_args += ["--checkpoint", c]
# Spawn the `osbuild` executable, feed it the specified data on
# `STDIN` and wait for completion. If we are interrupted, we always
# wait for `osbuild` to shut down, so we can clean up its file-system
# trees (they would trigger `EBUSY` if we didn't wait).
try:
p = subprocess.Popen(
["python3", "-m", "osbuild"] + cmd_args + ["-"],
encoding="utf-8",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
data_stdout, data_stderr = p.communicate(data_stdin)
except KeyboardInterrupt:
p.wait()
raise
# If execution failed, print results to `STDOUT`.
if p.returncode != 0:
self._print_result(p.returncode, data_stdout, data_stderr)
self._unittest.assertEqual(p.returncode, 0)
def compile_file(self, file_stdin, checkpoints=[]):
"""Compile an Artifact
This is similar to `compile()` but takes a file-path instead of raw
data. This will read the specified file into memory and then pass it
to `compile()`.
"""
with open(file_stdin, "r") as f:
data_stdin = f.read()
return self.compile(data_stdin, checkpoints=checkpoints)
def treeid_from_manifest(self, manifest_data):
"""Calculate Tree ID
This takes an in-memory manifest, inspects it, and returns the ID of
the final tree of the stage-array. This returns `None` if no stages
are defined.
"""
manifest_json = json.loads(manifest_data)
manifest_pipeline = manifest_json.get("pipeline", {})
manifest_sources = manifest_json.get("sources", {})
manifest_parsed = osbuild.load(manifest_pipeline, manifest_sources)
return manifest_parsed.tree_id
@contextlib.contextmanager
def map_object(self, obj):
"""Temporarily Map an Intermediate Object
This takes a cache-reference as input, looks it up in the current cache
and provides the file-path to this object back to the caller.
"""
path = os.path.join(self._cachedir, "refs", obj)
assert os.access(path, os.R_OK)
# Yield the path to the cache-entry to the caller. This is implemented
# as a context-manager so the caller does not retain the path for
# later access.
yield path
@contextlib.contextmanager
def map_output(self, filename):
"""Temporarily Map an Output Object
This takes a filename (or relative path) and looks it up in the output
directory. It then provides the absolute path to that file back to the
caller.
"""
path = os.path.join(self._outputdir, filename)
assert os.access(path, os.R_OK)
# Similar to `map_object()` we provide the path through a
# context-manager so the caller does not retain the path.
yield path