Merge "Add state object, rename "results", add unit tests"

This commit is contained in:
Jenkins 2017-05-31 05:52:24 +00:00 committed by Gerrit Code Review
commit 09543cf52b
17 changed files with 427 additions and 110 deletions

View File

@ -16,18 +16,87 @@ import codecs
import json
import logging
import os
import pprint
import shutil
import sys
import yaml
from diskimage_builder.block_device.config import config_tree_to_graph
from diskimage_builder.block_device.config import create_graph
from diskimage_builder.block_device.exception import \
BlockDeviceSetupException
from diskimage_builder.block_device.utils import exec_sudo
logger = logging.getLogger(__name__)
def _load_json(file_name):
"""Load file from .json file on disk, return None if not existing"""
if os.path.exists(file_name):
with codecs.open(file_name, encoding="utf-8", mode="r") as fd:
return json.load(fd)
return None
class BlockDeviceState(object):
"""The global state singleton
An reference to an instance of this object is passed between nodes
as a global repository. It contains a single dictionary "state"
and a range of helper functions.
This is used in two contexts:
- The state is built by the :func:`NodeBase.create` commands as
called during :func:`BlockDevice.cmd_create`. It is then
persisted to disk by :func:`save_state`
- Later calls (cleanup, umount, etc) load the state dictionary
from disk and are thus passed the full state.
"""
# XXX:
# - might it make sense to make state implement MutableMapping,
# so that callers treat it as a dictionary?
# - we could implement getters/setters such that if loaded from
# disk, the state is read-only? or make it append-only
# (i.e. you can't overwrite existing keys)
def __init__(self, filename=None):
"""Initialise state
:param filename: if :param:`filename` is passed and exists, it
will be loaded as the state. If it does not exist an
exception is raised. If :param:`filename` is not
passed, state will be initalised to a blank dictionary.
"""
if filename:
if not os.path.exists(filename):
raise BlockDeviceSetupException("State dump not found")
else:
self.state = _load_json(filename)
assert self.state is not None
else:
self.state = {}
def save_state(self, filename):
"""Persist the state to disk
:param filename: The file to persist state to
"""
logger.debug("Writing state to: %s", filename)
self.debug_dump()
with open(filename, "w") as fd:
json.dump(self.state, fd)
def debug_dump(self):
"""Log state to debug"""
# This is pretty good for human consumption, but maybe a bit
# verbose.
nice_output = pprint.pformat(self.state, width=40)
for line in nice_output.split('\n'):
logger.debug(" " + line)
class BlockDevice(object):
"""Handles block devices.
@ -116,13 +185,6 @@ class BlockDevice(object):
else:
v['label'] = "cloudimg-rootfs"
@staticmethod
def _load_json(file_name):
if os.path.exists(file_name):
with codecs.open(file_name, encoding="utf-8", mode="r") as fd:
return json.load(fd)
return None
def __init__(self, params):
"""Create BlockDevice object
@ -142,9 +204,7 @@ class BlockDevice(object):
self.config_json_file_name \
= os.path.join(self.state_dir, "config.json")
self.config = self._load_json(self.config_json_file_name)
self.state = self._load_json(self.state_json_file_name)
logger.debug("Using state [%s]", self.state)
self.config = _load_json(self.config_json_file_name)
# This needs to exists for the state and config files
try:
@ -152,16 +212,6 @@ class BlockDevice(object):
except OSError:
pass
def write_state(self, state):
logger.debug("Write state [%s]", self.state_json_file_name)
with open(self.state_json_file_name, "w") as fd:
json.dump(state, fd)
def create(self, result, rollback):
dg, call_order = create_graph(self.config, self.params)
for node in call_order:
node.create(result, rollback)
def cmd_init(self):
"""Initialize block device setup
@ -234,16 +284,23 @@ class BlockDevice(object):
# mountpoints list
print("%s" % "|".join(mount_points))
return 0
# the following symbols all come from the global state
# dictionary. They can only be accessed after the state has
# been dumped; i.e. after cmd_create() called.
state = BlockDeviceState(self.state_json_file_name)
state = state.state
if symbol == 'image-block-partition':
# If there is no partition needed, pass back directly the
# image.
if 'root' in self.state['blockdev']:
print("%s" % self.state['blockdev']['root']['device'])
if 'root' in state['blockdev']:
print("%s" % state['blockdev']['root']['device'])
else:
print("%s" % self.state['blockdev']['image0']['device'])
print("%s" % state['blockdev']['image0']['device'])
return 0
if symbol == 'image-path':
print("%s" % self.state['blockdev']['image0']['image'])
print("%s" % state['blockdev']['image0']['image'])
return 0
logger.error("Invalid symbol [%s] for getval", symbol)
@ -253,28 +310,33 @@ class BlockDevice(object):
"""Creates the fstab"""
logger.info("Creating fstab")
# State should have been created by prior calls; we only need
# the dict
state = BlockDeviceState(self.state_json_file_name)
state = state.state
tmp_fstab = os.path.join(self.state_dir, "fstab")
with open(tmp_fstab, "wt") as fstab_fd:
# This gives the order in which this must be mounted
for mp in self.state['mount_order']:
for mp in state['mount_order']:
logger.debug("Writing fstab entry for [%s]", mp)
fs_base = self.state['mount'][mp]['base']
fs_name = self.state['mount'][mp]['name']
fs_val = self.state['filesys'][fs_base]
fs_base = state['mount'][mp]['base']
fs_name = state['mount'][mp]['name']
fs_val = state['filesys'][fs_base]
if 'label' in fs_val:
diskid = "LABEL=%s" % fs_val['label']
else:
diskid = "UUID=%s" % fs_val['uuid']
# If there is no fstab entry - do not write anything
if 'fstab' not in self.state:
if 'fstab' not in state:
continue
if fs_name not in self.state['fstab']:
if fs_name not in state['fstab']:
continue
options = self.state['fstab'][fs_name]['options']
dump_freq = self.state['fstab'][fs_name]['dump-freq']
fsck_passno = self.state['fstab'][fs_name]['fsck-passno']
options = state['fstab'][fs_name]['options']
dump_freq = state['fstab'][fs_name]['dump-freq']
fsck_passno = state['fstab'][fs_name]['fsck-passno']
fstab_fd.write("%s %s %s %s %s %s\n"
% (diskid, mp, fs_val['fstype'],
@ -292,25 +354,34 @@ class BlockDevice(object):
logger.info("create() called")
logger.debug("Using config [%s]", self.config)
self.state = {}
rollback = []
# Create a new, empty state
state = BlockDeviceState()
try:
self.create(self.state, rollback)
dg, call_order = create_graph(self.config, self.params)
for node in call_order:
node.create(state.state, rollback)
except Exception:
logger.exception("Create failed; rollback initiated")
for rollback_cb in reversed(rollback):
rollback_cb()
sys.exit(1)
self.write_state(self.state)
state.save_state(self.state_json_file_name)
logger.info("create() finished")
return 0
def cmd_umount(self):
"""Unmounts the blockdevice and cleanup resources"""
if self.state is None:
# State should have been created by prior calls; we only need
# the dict. If it is not here, it has been cleaned up already
# (? more details?)
try:
state = BlockDeviceState(self.state_json_file_name)
state = state.state
except BlockDeviceSetupException:
logger.info("State already cleaned - no way to do anything here")
return 0
@ -321,36 +392,44 @@ class BlockDevice(object):
if dg is None:
return 0
for node in reverse_order:
node.umount(self.state)
node.umount(state)
return 0
def cmd_cleanup(self):
"""Cleanup all remaining relicts - in good case"""
# State should have been created by prior calls; we only need
# the dict
state = BlockDeviceState(self.state_json_file_name)
state = state.state
# Deleting must be done in reverse order
dg, call_order = create_graph(self.config, self.params)
reverse_order = reversed(call_order)
for node in reverse_order:
node.cleanup(self.state)
node.cleanup(state)
logger.info("Removing temporary dir [%s]", self.state_dir)
logger.info("Removing temporary state dir [%s]", self.state_dir)
shutil.rmtree(self.state_dir)
return 0
def cmd_delete(self):
"""Cleanup all remaining relicts - in case of an error"""
# State should have been created by prior calls; we only need
# the dict
state = BlockDeviceState(self.state_json_file_name)
state = state.state
# Deleting must be done in reverse order
dg, call_order = create_graph(self.config, self.params)
reverse_order = reversed(call_order)
for node in reverse_order:
node.delete(self.state)
node.delete(state)
logger.info("Removing temporary dir [%s]", self.state_dir)
logger.info("Removing temporary state dir [%s]", self.state_dir)
shutil.rmtree(self.state_dir)
return 0

View File

@ -100,7 +100,7 @@ class LocalLoopNode(NodeBase):
logger.debug("Gave up trying to detach [%s]", loopdev)
return rval
def create(self, result, rollback):
def create(self, state, rollback):
logger.debug("[%s] Creating loop on [%s] with size [%d]",
self.name, self.filename, self.size)
@ -110,11 +110,11 @@ class LocalLoopNode(NodeBase):
block_device = self._loopdev_attach(self.filename)
rollback.append(lambda: self._loopdev_detach(block_device))
if 'blockdev' not in result:
result['blockdev'] = {}
if 'blockdev' not in state:
state['blockdev'] = {}
result['blockdev'][self.name] = {"device": block_device,
"image": self.filename}
state['blockdev'][self.name] = {"device": block_device,
"image": self.filename}
logger.debug("Created loop name [%s] device [%s] image [%s]",
self.name, block_device, self.filename)
return

View File

@ -65,5 +65,5 @@ class PartitionNode(NodeBase):
edge_from.append(self.prev_partition.name)
return (edge_from, edge_to)
def create(self, result, rollback):
self.partitioning.create(result, rollback)
def create(self, state, rollback):
self.partitioning.create(state, rollback)

View File

@ -127,14 +127,13 @@ class Partitioning(PluginBase):
exec_sudo(["kpartx", "-avs", device_path])
def create(self, result, rollback):
def create(self, state, rollback):
# not this is NOT a node and this is not called directly! The
# create() calls in the partition nodes this plugin has
# created are calling back into this.
image_path = result['blockdev'][self.base]['image']
device_path = result['blockdev'][self.base]['device']
logger.info("Creating partition on [%s] [%s]",
self.base, image_path)
image_path = state['blockdev'][self.base]['image']
device_path = state['blockdev'][self.base]['device']
logger.info("Creating partition on [%s] [%s]", self.base, image_path)
# This is a bit of a hack. Each of the partitions is actually
# in the graph, so for every partition we get a create() call
@ -167,7 +166,7 @@ class Partitioning(PluginBase):
logger.debug("Create partition [%s] [%d]",
part_name, part_no)
partition_device_name = device_path + "p%d" % part_no
result['blockdev'][part_name] \
state['blockdev'][part_name] \
= {'device': partition_device_name}
partition_devices.add(partition_device_name)

View File

@ -102,9 +102,7 @@ class FilesystemNode(NodeBase):
edge_to = []
return (edge_from, edge_to)
def create(self, result, rollback):
logger.info("create called; result [%s]", result)
def create(self, state, rollback):
cmd = ["mkfs"]
cmd.extend(['-t', self.type])
@ -123,17 +121,17 @@ class FilesystemNode(NodeBase):
if self.type in ('ext2', 'ext3', 'ext4', 'xfs'):
cmd.append('-q')
if 'blockdev' not in result:
result['blockdev'] = {}
device = result['blockdev'][self.base]['device']
if 'blockdev' not in state:
state['blockdev'] = {}
device = state['blockdev'][self.base]['device']
cmd.append(device)
logger.debug("Creating fs command [%s]", cmd)
exec_sudo(cmd)
if 'filesys' not in result:
result['filesys'] = {}
result['filesys'][self.name] \
if 'filesys' not in state:
state['filesys'] = {}
state['filesys'][self.name] \
= {'uuid': self.uuid, 'label': self.label,
'fstype': self.type, 'opts': self.opts,
'device': device}

View File

@ -91,9 +91,8 @@ class MountPointNode(NodeBase):
edge_from.append(self.base)
return (edge_from, edge_to)
def create(self, result, rollback):
def create(self, state, rollback):
logger.debug("mount called [%s]", self.mount_point)
logger.debug("result [%s]", result)
rel_mp = self.mount_point if self.mount_point[0] != '/' \
else self.mount_point[1:]
mount_point = os.path.join(self.mount_base, rel_mp)
@ -102,17 +101,17 @@ class MountPointNode(NodeBase):
# file system tree.
exec_sudo(['mkdir', '-p', mount_point])
logger.info("Mounting [%s] to [%s]", self.name, mount_point)
exec_sudo(["mount", result['filesys'][self.base]['device'],
exec_sudo(["mount", state['filesys'][self.base]['device'],
mount_point])
if 'mount' not in result:
result['mount'] = {}
result['mount'][self.mount_point] \
if 'mount' not in state:
state['mount'] = {}
state['mount'][self.mount_point] \
= {'name': self.name, 'base': self.base, 'path': mount_point}
if 'mount_order' not in result:
result['mount_order'] = []
result['mount_order'].append(self.mount_point)
if 'mount_order' not in state:
state['mount_order'] = []
state['mount_order'].append(self.mount_point)
def umount(self, state):
logger.info("Called for [%s]", self.name)

View File

@ -34,14 +34,13 @@ class FstabNode(NodeBase):
edge_to = []
return (edge_from, edge_to)
def create(self, result, rollback):
def create(self, state, rollback):
logger.debug("fstab create called [%s]", self.name)
logger.debug("result [%s]", result)
if 'fstab' not in result:
result['fstab'] = {}
if 'fstab' not in state:
state['fstab'] = {}
result['fstab'][self.base] = {
state['fstab'][self.base] = {
'name': self.name,
'base': self.base,
'options': self.options,

View File

@ -74,7 +74,7 @@ class NodeBase(object):
return
@abc.abstractmethod
def create(self, results, rollback):
def create(self, state, rollback):
"""Main creation driver
This is the main driver function. After the graph is
@ -82,10 +82,11 @@ class NodeBase(object):
Arguments:
:param results: A shared dictionary of prior results. This
:param state: A shared dictionary of prior results. This
dictionary is passed by reference to each call, meaning any
entries inserted will be available to subsequent :func:`create`
calls of following nodes.
calls of following nodes. The ``state`` dictionary will be
saved and available to other calls.
:param rollback: A shared list of functions to be called in
the failure case. Nodes should only append to this list.
@ -105,7 +106,7 @@ class NodeBase(object):
Actions to taken when ``dib-block-device umount`` is called
:param state: the current state dictionary. This is the
`results` dictionary from :func:`create` before this call is
`state` dictionary from :func:`create` before this call is
made.
:return: None
"""
@ -119,7 +120,7 @@ class NodeBase(object):
called in the reverse order to :func:`create`
:param state: the current state dictionary. This is the
`results` dictionary from :func:`create` before this call is
`state` dictionary from :func:`create` before this call is
made.
:return: None
"""
@ -134,7 +135,7 @@ class NodeBase(object):
:func:`create`
:param state: the current state dictionary. This is the
`results` dictionary from :func:`create` before this call is
`state` dictionary from :func:`create` before this call is
made.
:return: None
"""

View File

@ -0,0 +1,6 @@
- test_a:
name: test_node_a
- test_b:
name: test_node_b
base: test_node_a

View File

@ -0,0 +1,49 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# plugin test case
import logging
from diskimage_builder.block_device.plugin import NodeBase
from diskimage_builder.block_device.plugin import PluginBase
logger = logging.getLogger(__name__)
class TestANode(NodeBase):
def __init__(self, name):
logger.debug("Create test 1")
super(TestANode, self).__init__(name)
def get_edges(self):
# this is like the loop node; it's a root and doesn't have a
# base
return ([], [])
def create(self, state, rollback):
# put some fake entries into state
state['test_a'] = {}
state['test_a']['value'] = 'foo'
state['test_a']['value2'] = 'bar'
return
class TestA(PluginBase):
def __init__(self, config, defaults):
super(PluginBase, self).__init__()
self.node = TestANode(config['name'])
def get_nodes(self):
return [self.node]

View File

@ -0,0 +1,47 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# plugin test case
import logging
from diskimage_builder.block_device.plugin import NodeBase
from diskimage_builder.block_device.plugin import PluginBase
logger = logging.getLogger(__name__)
class TestBNode(NodeBase):
def __init__(self, name, base):
logger.debug("Create test 1")
super(TestBNode, self).__init__(name)
self.base = base
def get_edges(self):
return ([self.base], [])
def create(self, state, rollback):
state['test_b'] = {}
state['test_b']['value'] = 'baz'
return
class TestB(PluginBase):
def __init__(self, config, defaults):
super(PluginBase, self).__init__()
self.node = TestBNode(config['name'],
config['base'])
def get_nodes(self):
return [self.node]

View File

@ -0,0 +1,41 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import fixtures
import logging
import os
import testtools
import yaml
logger = logging.getLogger(__name__)
class TestBase(testtools.TestCase):
"""Base for all test cases"""
def setUp(self):
super(TestBase, self).setUp()
fs = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
self.log_fixture = self.useFixture(
fixtures.FakeLogger(level=logging.DEBUG, format=fs))
def get_config_file(self, f):
"""Get the full path to sample config file f """
logger.debug(os.path.dirname(__file__))
return os.path.join(os.path.dirname(__file__), 'config', f)
def load_config_file(self, f):
"""Load f and return it after yaml parsing"""
path = self.get_config_file(f)
with open(path, 'r') as config:
return yaml.safe_load(config)

View File

@ -10,30 +10,23 @@
# License for the specific language governing permissions and limitations
# under the License.
import fixtures
import logging
import os
import testtools
import yaml
from diskimage_builder.block_device.config import config_tree_to_graph
from diskimage_builder.block_device.config import create_graph
from diskimage_builder.block_device.exception import \
BlockDeviceSetupException
from diskimage_builder.block_device.tests.test_base import TestBase
logger = logging.getLogger(__name__)
class TestConfig(testtools.TestCase):
class TestConfig(TestBase):
"""Helper for setting up and reading a config"""
def setUp(self):
super(TestConfig, self).setUp()
fs = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
self.log_fixture = self.useFixture(
fixtures.FakeLogger(level=logging.DEBUG, format=fs))
# reset all globals for each test.
# XXX: remove globals :/
import diskimage_builder.block_device.level2.mkfs
@ -42,12 +35,6 @@ class TestConfig(testtools.TestCase):
diskimage_builder.block_device.level3.mount.mount_points = {}
diskimage_builder.block_device.level3.mount.sorted_mount_points = None
def load_config_file(self, f):
path = os.path.join(os.path.dirname(__file__),
'config', f)
with open(path, 'r') as config:
return yaml.safe_load(config)
class TestGraphGeneration(TestConfig):
"""Extra helper class for testing graph generation"""

View File

@ -30,14 +30,14 @@ class TestMountOrder(tc.TestGraphGeneration):
graph, call_order = create_graph(config, self.fake_default_config)
result = {}
result['filesys'] = {}
result['filesys']['mkfs_root'] = {}
result['filesys']['mkfs_root']['device'] = 'fake'
result['filesys']['mkfs_var'] = {}
result['filesys']['mkfs_var']['device'] = 'fake'
result['filesys']['mkfs_var_log'] = {}
result['filesys']['mkfs_var_log']['device'] = 'fake'
state = {}
state['filesys'] = {}
state['filesys']['mkfs_root'] = {}
state['filesys']['mkfs_root']['device'] = 'fake'
state['filesys']['mkfs_var'] = {}
state['filesys']['mkfs_var']['device'] = 'fake'
state['filesys']['mkfs_var_log'] = {}
state['filesys']['mkfs_var_log']['device'] = 'fake'
rollback = []
@ -46,7 +46,7 @@ class TestMountOrder(tc.TestGraphGeneration):
# XXX: do we even need to create? We could test the
# sudo arguments from the mock in the below asserts
# too
node.create(result, rollback)
node.create(state, rollback)
# ensure that partitions are mounted in order root->var->var/log
self.assertListEqual(result['mount_order'], ['/', '/var', '/var/log'])
self.assertListEqual(state['mount_order'], ['/', '/var', '/var/log'])

View File

@ -0,0 +1,107 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import codecs
import fixtures
import json
import logging
import os
from stevedore import extension
from testtools.matchers import FileExists
import diskimage_builder.block_device.blockdevice as bd
import diskimage_builder.block_device.tests.test_base as tb
from diskimage_builder.block_device.exception import \
BlockDeviceSetupException
logger = logging.getLogger(__name__)
class TestStateBase(tb.TestBase):
def setUp(self):
super(TestStateBase, self).setUp()
# override the extensions to the test extensions
test_extensions = extension.ExtensionManager(
namespace='diskimage_builder.block_device.plugin_test',
invoke_on_load=False)
extensions_fixture = fixtures.MonkeyPatch(
'diskimage_builder.block_device.config._extensions',
test_extensions)
self.useFixture(extensions_fixture)
# status and other bits saved here
self.build_dir = fixtures.TempDir()
self.useFixture(self.build_dir)
class TestState(TestStateBase):
# The the state generation & saving methods
def test_state_create(self):
params = {
'build-dir': self.build_dir.path,
'config': self.get_config_file('cmd_create.yaml')
}
bd_obj = bd.BlockDevice(params)
bd_obj.cmd_init()
bd_obj.cmd_create()
# cmd_create should have persisted this to disk
state_file = os.path.join(self.build_dir.path,
'states', 'block-device',
'state.json')
self.assertThat(state_file, FileExists())
# ensure we see the values put in by the test extensions
# persisted
with codecs.open(state_file, encoding='utf-8', mode='r') as fd:
state = json.load(fd)
self.assertDictEqual(state,
{'test_a': {'value': 'foo',
'value2': 'bar'},
'test_b': {'value': 'baz'}})
# Test state going missing between phases
def test_missing_state(self):
params = {
'build-dir': self.build_dir.path,
'config': self.get_config_file('cmd_create.yaml')
}
bd_obj = bd.BlockDevice(params)
bd_obj.cmd_init()
bd_obj.cmd_create()
# cmd_create should have persisted this to disk
state_file = os.path.join(self.build_dir.path,
'states', 'block-device',
'state.json')
self.assertThat(state_file, FileExists())
# simulate the state somehow going missing, and ensure that
# later calls notice
os.unlink(state_file)
self.assertRaisesRegexp(BlockDeviceSetupException,
"State dump not found",
bd_obj.cmd_cleanup)
self.assertRaisesRegexp(BlockDeviceSetupException,
"State dump not found",
bd_obj.cmd_writefstab)
self.assertRaisesRegexp(BlockDeviceSetupException,
"State dump not found",
bd_obj.cmd_delete)

View File

@ -71,3 +71,8 @@ diskimage_builder.block_device.plugin =
mkfs = diskimage_builder.block_device.level2.mkfs:Mkfs
mount = diskimage_builder.block_device.level3.mount:Mount
fstab = diskimage_builder.block_device.level4.fstab:Fstab
# unit test extensions
diskimage_builder.block_device.plugin_test =
test_a = diskimage_builder.block_device.tests.plugin.test_a:TestA
test_b = diskimage_builder.block_device.tests.plugin.test_b:TestB