diff --git a/kiwi/chroot_manager.py b/kiwi/chroot_manager.py new file mode 100644 index 00000000000..912004b359e --- /dev/null +++ b/kiwi/chroot_manager.py @@ -0,0 +1,126 @@ +# Copyright (c) 2025 SUSE Software Solutions Germany GmbH. All rights reserved. +# +# This file is part of kiwi. +# +# kiwi is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# kiwi 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with kiwi. If not, see +# +import os +import logging +from typing import ( + List, Optional +) + +# project +from kiwi.mount_manager import MountManager +from kiwi.command import ( + Command, CommandT, MutableMapping +) +from kiwi.exceptions import ( + KiwiUmountBusyError +) + +log = logging.getLogger('kiwi') + + +class ChrootManager: + """ + **Implements methods for setting and unsetting a chroot environment** + + The caller is responsible for cleaning up bind mounts if the ChrootManager + is used as is, without a context. + + The class also supports to be used as a context manager, where any bind or kernel + filesystem mount is unmounted once the context manager's with block is left + + * :param string root_dir: path to change the root to + * :param list binds: current root paths to bind to the chrooted path + """ + def __init__(self, root_dir: str, binds: List[str] = []): + self.root_dir = root_dir + self.mounts: List[MountManager] = [] + for bind in binds: + self.mounts.append(MountManager( + device=bind, mountpoint=os.path.normpath( + os.sep.join([root_dir, bind]) + ) + )) + + def __enter__(self) -> "ChrootManager": + try: + self.mount() + except Exception as e: + try: + self.umount() + except Exception: + pass + raise e + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.umount() + + def mount(self) -> None: + """ + Mounts binds to the chroot path + """ + for mnt in self.mounts: + mnt.bind_mount() + + def umount(self) -> None: + """ + Unmounts all binds from the chroot path + + If any unmount raises a KiwiUmountBusyError this is trapped + and kept until the iteration over all bind mounts is over. + """ + errors = [] + for mnt in reversed(self.mounts): + try: + mnt.umount() + except KiwiUmountBusyError as e: + errors.append(e) + + if errors: + raise KiwiUmountBusyError(errors) + + def run( + self, command: List[str], + custom_env: Optional[MutableMapping[str, str]] = None, + raise_on_error: bool = True, stderr_to_stdout: bool = False, + raise_on_command_not_found: bool = True + ) -> Optional[CommandT]: + """ + This is a wrapper for Command.run method but pre-appending the + chroot call at the command list + + :param list command: command and arguments + :param dict custom_env: custom os.environ + :param bool raise_on_error: control error behaviour + :param bool stderr_to_stdout: redirects stderr to stdout + + :return: + Contains call results in command type + + .. code:: python + + CommandT(output='string', error='string', returncode=int) + + :rtype: CommandT + """ + chroot_cmd = ['chroot', self.root_dir] + chroot_cmd = chroot_cmd + command + return Command.run( + chroot_cmd, custom_env, raise_on_error, stderr_to_stdout, + raise_on_command_not_found + ) diff --git a/kiwi/volume_manager/btrfs.py b/kiwi/volume_manager/btrfs.py index 00ec3ebd26b..93abd145730 100644 --- a/kiwi/volume_manager/btrfs.py +++ b/kiwi/volume_manager/btrfs.py @@ -26,10 +26,12 @@ # project from kiwi.command import Command +from kiwi.chroot_manager import ChrootManager from kiwi.volume_manager.base import VolumeManagerBase from kiwi.mount_manager import MountManager from kiwi.storage.mapped_device import MappedDevice from kiwi.filesystem import FileSystem +from kiwi.utils.command_capabilities import CommandCapabilities from kiwi.utils.sync import DataSync from kiwi.utils.block import BlockID from kiwi.utils.sysconfig import SysConfig @@ -135,24 +137,11 @@ def setup(self, name=None): ['btrfs', 'subvolume', 'create', root_volume] ) if self.custom_args['root_is_snapper_snapshot']: - snapshot_volume = self.mountpoint + \ - f'/{self.root_volume_name}/.snapshots' - Command.run( - ['btrfs', 'subvolume', 'create', snapshot_volume] - ) - os.chmod(snapshot_volume, 0o700) - Path.create(snapshot_volume + '/1') - snapshot = self.mountpoint + \ - f'/{self.root_volume_name}/.snapshots/1/snapshot' - Command.run( - ['btrfs', 'subvolume', 'create', snapshot] - ) - self._set_default_volume( - f'{self.root_volume_name}/.snapshots/1/snapshot' - ) + self._create_first_snapper_snapshot_as_default() + + # Mount /{some-name}/.snapshots as /.snapshots inside the root snapshot = self.mountpoint + \ f'/{self.root_volume_name}/.snapshots/1/snapshot' - # Mount /{some-name}/.snapshots as /.snapshots inside the root snapshots_mount = MountManager( device=self.device, attributes={ @@ -413,19 +402,17 @@ def sync_data(self, exclude=None): """ if self.toplevel_mount: sync_target = self.get_mountpoint() - if self.custom_args['root_is_snapper_snapshot']: - self._create_snapshot_info( - ''.join( - [ - self.mountpoint, - f'/{self.root_volume_name}/.snapshots/1/info.xml' - ] - ) - ) data = DataSync(self.root_dir, sync_target) data.sync_data( options=Defaults.get_sync_options(), exclude=exclude ) + if self.custom_args['root_is_snapper_snapshot']: + self._create_snapshot_info( + os.sep.join([ + self.mountpoint, + f'{self.root_volume_name}/.snapshots/1' + ]) + ) if self.custom_args['quota_groups'] and \ self.custom_args['root_is_snapper_snapshot']: self._create_snapper_quota_configuration() @@ -528,10 +515,10 @@ def _create_snapper_quota_configuration(self): config_file = self._set_snapper_sysconfig_file(root_path) if not os.path.exists(config_file): shutil.copyfile(snapper_default_conf, config_file) - Command.run([ - 'chroot', root_path, 'snapper', '--no-dbus', 'set-config', - 'QGROUP=1/0' - ]) + with ChrootManager(root_path) as chroot: + chroot.run([ + 'snapper', '--no-dbus', 'set-config', 'QGROUP=1/0' + ]) @staticmethod def _set_snapper_sysconfig_file(root_path): @@ -554,24 +541,66 @@ def _set_snapper_sysconfig_file(root_path): sysconf_file['SNAPPER_CONFIGS'].strip('\"')] ) - def _create_snapshot_info(self, filename): - date_info = datetime.datetime.now() - snapshot = ElementTree.Element('snapshot') + def _create_first_snapper_snapshot_as_default(self): + if CommandCapabilities.check_version( + 'snapper', (0, 12, 1), root=self.root_dir + ): + with ChrootManager( + self.root_dir, binds=[self.mountpoint] + ) as chroot: + chroot.run([ + '/usr/lib/snapper/installation-helper', '--root-prefix', + os.sep.join([self.mountpoint, self.root_volume_name]), + '--step', 'filesystem' + ]) + else: + snapshot_volume = self.mountpoint + \ + f'/{self.root_volume_name}/.snapshots' + Command.run( + ['btrfs', 'subvolume', 'create', snapshot_volume] + ) + os.chmod(snapshot_volume, 0o700) + Path.create(snapshot_volume + '/1') + snapshot = self.mountpoint + \ + f'/{self.root_volume_name}/.snapshots/1/snapshot' + Command.run( + ['btrfs', 'subvolume', 'create', snapshot] + ) + self._set_default_volume( + f'{self.root_volume_name}/.snapshots/1/snapshot' + ) + + def _create_snapshot_info(self, path): + if CommandCapabilities.check_version( + 'snapper', (0, 12, 1), root=self.mountpoint + ): + snapshots_prefix = os.sep.join([path, '.snapshots']) + with ChrootManager( + self.root_dir, binds=[path, snapshots_prefix] + ) as chroot: + chroot.run([ + '/usr/lib/snapper/installation-helper', '--root-prefix', + path, '--step', 'config', '--description', + 'first root filesystem' + ]) + else: + date_info = datetime.datetime.now() + snapshot = ElementTree.Element('snapshot') - snapshot_type = ElementTree.SubElement(snapshot, 'type') - snapshot_type.text = 'single' + snapshot_type = ElementTree.SubElement(snapshot, 'type') + snapshot_type.text = 'single' - snapshot_number = ElementTree.SubElement(snapshot, 'num') - snapshot_number.text = '1' + snapshot_number = ElementTree.SubElement(snapshot, 'num') + snapshot_number.text = '1' - snapshot_description = ElementTree.SubElement(snapshot, 'description') - snapshot_description.text = 'first root filesystem' + snapshot_description = ElementTree.SubElement(snapshot, 'description') + snapshot_description.text = 'first root filesystem' - snapshot_date = ElementTree.SubElement(snapshot, 'date') - snapshot_date.text = date_info.strftime("%Y-%m-%d %H:%M:%S") + snapshot_date = ElementTree.SubElement(snapshot, 'date') + snapshot_date.text = date_info.strftime("%Y-%m-%d %H:%M:%S") - with open(filename, 'w') as snapshot_info_file: - snapshot_info_file.write(self._xml_pretty(snapshot)) + with open(os.path.join(path, 'info.xml'), 'w') as snapshot_info_file: + snapshot_info_file.write(self._xml_pretty(snapshot)) def __exit__(self, exc_type, exc_value, traceback): if self.toplevel_mount: diff --git a/test/unit/chroot_manager_test.py b/test/unit/chroot_manager_test.py new file mode 100644 index 00000000000..3344eeeb988 --- /dev/null +++ b/test/unit/chroot_manager_test.py @@ -0,0 +1,125 @@ +from unittest.mock import ( + patch, call, Mock +) +from pytest import raises + +from kiwi.chroot_manager import ChrootManager + +from kiwi.exceptions import ( + KiwiCommandError, + KiwiUmountBusyError +) + + +class TestChrootManager: + @patch('kiwi.chroot_manager.MountManager') + @patch('kiwi.chroot_manager.Command.run') + def test_run_in_chroot_context(self, mock_run, mock_mount): + mock_mntMngr = Mock() + mock_mount.return_value = mock_mntMngr + with ChrootManager( + '/some/root', binds=['/dev', '/proc', '/sys'] + ) as chroot: + chroot.run(['cmd', 'arg1', 'arg2']) + assert mock_mount.call_args_list == [ + call(device='/dev', mountpoint='/some/root/dev'), + call(device='/proc', mountpoint='/some/root/proc'), + call(device='/sys', mountpoint='/some/root/sys') + ] + mock_run.assert_called_once_with( + ['chroot', '/some/root', 'cmd', 'arg1', 'arg2'], + None, True, False, True + ) + mock_mntMngr.bind_mount.assert_called() + mock_mntMngr.umount.assert_called() + + @patch('kiwi.chroot_manager.MountManager') + @patch('kiwi.chroot_manager.Command.run') + def test_run_fails_in_chroot_context(self, mock_run, mock_mount): + mock_mntMngr = Mock() + mock_mount.return_value = mock_mntMngr + mock_run.side_effect = Exception + with ChrootManager( + '/some/root', binds=['/dev', '/proc', '/sys'] + ) as chroot: + with raises(Exception): + chroot.run(['cmd', 'arg1', 'arg2']) + assert mock_mount.call_args_list == [ + call(device='/dev', mountpoint='/some/root/dev'), + call(device='/proc', mountpoint='/some/root/proc'), + call(device='/sys', mountpoint='/some/root/sys') + ] + mock_run.assert_called_once_with( + ['chroot', '/some/root', 'cmd', 'arg1', 'arg2'], + None, True, False, True + ) + mock_mntMngr.bind_mount.assert_called() + mock_mntMngr.umount.assert_called() + + @patch('kiwi.chroot_manager.MountManager') + @patch('kiwi.chroot_manager.Command.run') + def test_run_fails_to_enter_chroot_context(self, mock_run, mock_mount): + mock_mntMngr = Mock() + mock_mount.return_value = mock_mntMngr + mock_mntMngr.bind_mount.side_effect = KiwiCommandError('mount error') + with raises(KiwiCommandError): + with ChrootManager( + '/some/root', binds=['/dev', '/proc', '/sys'] + ) as chroot: + chroot.run(['cmd', 'arg1', 'arg2']) + assert mock_mount.call_args_list == [ + call(device='/dev', mountpoint='/some/root/dev'), + call(device='/proc', mountpoint='/some/root/proc'), + call(device='/sys', mountpoint='/some/root/sys') + ] + mock_run.assert_not_called() + mock_mntMngr.bind_mount.assert_called_once() + mock_mntMngr.umount.assert_called() + + @patch('kiwi.chroot_manager.MountManager') + @patch('kiwi.chroot_manager.Command.run') + def test_run_fails_to_enter_and_clean_chroot_context( + self, mock_run, mock_mount + ): + mock_mntMngr = Mock() + mock_mount.return_value = mock_mntMngr + mock_mntMngr.bind_mount.side_effect = KiwiCommandError('mount error') + mock_mntMngr.umount.side_effect = KiwiUmountBusyError('umount error') + with raises(KiwiCommandError): + with ChrootManager( + '/some/root', binds=['/dev', '/proc', '/sys'] + ) as chroot: + chroot.run(['cmd', 'arg1', 'arg2']) + assert mock_mount.call_args_list == [ + call(device='/dev', mountpoint='/some/root/dev'), + call(device='/proc', mountpoint='/some/root/proc'), + call(device='/sys', mountpoint='/some/root/sys') + ] + mock_run.assert_not_called() + mock_mntMngr.bind_mount.assert_called_once() + mock_mntMngr.umount.assert_called() + + @patch('kiwi.chroot_manager.MountManager') + @patch('kiwi.chroot_manager.Command.run') + def test_run_fails_to_clean_chroot_context( + self, mock_run, mock_mount + ): + mock_mntMngr = Mock() + mock_mount.return_value = mock_mntMngr + mock_mntMngr.umount.side_effect = KiwiUmountBusyError('umount error') + with raises(KiwiUmountBusyError): + with ChrootManager( + '/some/root', binds=['/dev', '/proc', '/sys'] + ) as chroot: + chroot.run(['cmd', 'arg1', 'arg2']) + assert mock_mount.call_args_list == [ + call(device='/dev', mountpoint='/some/root/dev'), + call(device='/proc', mountpoint='/some/root/proc'), + call(device='/sys', mountpoint='/some/root/sys') + ] + mock_run.assert_called_once_with( + ['chroot', '/some/root', 'cmd', 'arg1', 'arg2'], + None, True, False, True + ) + mock_mntMngr.bind_mount.assert_called() + mock_mntMngr.umount.assert_called() diff --git a/test/unit/volume_manager/btrfs_test.py b/test/unit/volume_manager/btrfs_test.py index bfb8d3f7b52..99de6187017 100644 --- a/test/unit/volume_manager/btrfs_test.py +++ b/test/unit/volume_manager/btrfs_test.py @@ -132,15 +132,25 @@ def test_setup_with_snapshot( self, mock_Temporary, mock_mount, mock_mapped_device, mock_fs, mock_command, mock_os_exists, mock_os_chmod ): + def return_snapper_version(cmd, *args): + mock = Mock() + snapperCmd = ['chroot', 'snapper', '--version'] + subCmd = [element for element in cmd if element in snapperCmd] + if snapperCmd == subCmd: + mock = Mock() + mock.output = 'snapper 0.12.0' + else: + mock.output = \ + 'ID 258 gen 26 top level 257 path @/.snapshots/1/snapshot' + return mock + + mock_command.side_effect = return_snapper_version + mock_Temporary.return_value.new_dir.return_value.name = 'tmpdir' toplevel_mount = Mock() mock_mount.return_value = toplevel_mount - command_call = Mock() - command_call.output = \ - 'ID 258 gen 26 top level 257 path @/.snapshots/1/snapshot' mock_mapped_device.return_value = 'mapped_device' mock_os_exists.return_value = False - mock_command.return_value = command_call self.volume_manager.custom_args['root_is_snapper_snapshot'] = True self.volume_manager.custom_args['quota_groups'] = True @@ -161,6 +171,7 @@ def test_setup_with_snapshot( assert mock_command.call_args_list == [ call(['btrfs', 'quota', 'enable', 'tmpdir']), call(['btrfs', 'subvolume', 'create', 'tmpdir/@']), + call(['chroot', 'root_dir', 'snapper', '--version']), call(['btrfs', 'subvolume', 'create', 'tmpdir/@/.snapshots']), call(['btrfs', 'subvolume', 'create', 'tmpdir/@/.snapshots/1/snapshot']), call(['btrfs', 'subvolume', 'list', 'tmpdir']), @@ -168,6 +179,73 @@ def test_setup_with_snapshot( ] mock_os_chmod.assert_called_once_with('tmpdir/@/.snapshots', 0o700) + @patch('os.chmod') + @patch('os.path.exists') + @patch('kiwi.volume_manager.btrfs.Command.run') + @patch('kiwi.volume_manager.btrfs.FileSystem.new') + @patch('kiwi.volume_manager.btrfs.MappedDevice') + @patch('kiwi.volume_manager.btrfs.MountManager') + @patch('kiwi.volume_manager.base.Temporary') + def test_setup_with_snapshot_helpers( + self, mock_Temporary, mock_mount, mock_mapped_device, mock_fs, + mock_command, mock_os_exists, mock_os_chmod + ): + def return_snapper_version(command=None, raise_on_error=None, *args): + mock = Mock() + snapperCmd = ['chroot', 'snapper', '--version'] + subCmd = [element for element in command if element in snapperCmd] + if snapperCmd == subCmd: + mock = Mock() + mock.output = 'snapper 0.12.1' + else: + mock.output = \ + 'ID 258 gen 26 top level 257 path @/.snapshots/1/snapshot' + mock.return_code = 0 + return mock + + mock_command.side_effect = return_snapper_version + + mock_Temporary.return_value.new_dir.return_value.name = 'tmpdir' + toplevel_mount = Mock() + mock_mount.return_value = toplevel_mount + mock_mapped_device.return_value = 'mapped_device' + mock_os_exists.return_value = False + self.volume_manager.custom_args['root_is_snapper_snapshot'] = True + self.volume_manager.custom_args['quota_groups'] = True + + self.volume_manager.setup() + + assert mock_mount.call_args_list == [ + call(device='/dev/storage', mountpoint='tmpdir'), + call( + device='/dev/storage', + attributes={ + 'subvol_path': '@/.snapshots', + 'subvol_name': '@/.snapshots' + }, + mountpoint='tmpdir/@/.snapshots/1/snapshot/.snapshots' + ) + ] + toplevel_mount.mount.assert_called_once_with([]) + assert mock_command.call_args_list == [ + call(['btrfs', 'quota', 'enable', 'tmpdir']), + call(['btrfs', 'subvolume', 'create', 'tmpdir/@']), + call(['chroot', 'root_dir', 'snapper', '--version']), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir'], + raise_on_error=False + ), + call(['mount', '-n', '--bind', 'tmpdir', 'root_dir/tmpdir']), + call([ + 'chroot', 'root_dir', '/usr/lib/snapper/installation-helper', + '--root-prefix', 'tmpdir/@', '--step', 'filesystem' + ], None, True, False, True), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir'], + raise_on_error=False + ), + ] + @patch('os.path.exists') @patch('kiwi.volume_manager.btrfs.Command.run') @patch('kiwi.volume_manager.btrfs.FileSystem.new') @@ -436,6 +514,16 @@ def exists(name): return False return True + def return_snapper_version(cmd, *args): + snapperCmd = ['chroot', 'snapper', '--version'] + subCmd = [element for element in cmd if element in snapperCmd] + if snapperCmd == subCmd: + mock = Mock() + mock.output = 'snapper 0.12.0' + return mock + + mock_command.side_effect = return_snapper_version + self.volume_manager.custom_args['quota_groups'] = True mock_exists.side_effect = exists @@ -479,11 +567,116 @@ def exists(name): call(minidom.parseString(xml_info).toprettyxml(indent=" ")) ] assert mock_command.call_args_list == [ + call(['chroot', 'tmpdir', 'snapper', '--version']), call(['btrfs', 'qgroup', 'create', '1/0', 'tmpdir']), call([ 'chroot', 'tmpdir/@/.snapshots/1/snapshot', 'snapper', '--no-dbus', 'set-config', 'QGROUP=1/0' - ]) + ], None, True, False, True) + ] + + @patch('kiwi.volume_manager.btrfs.SysConfig') + @patch('kiwi.volume_manager.btrfs.DataSync') + @patch('kiwi.volume_manager.btrfs.Command.run') + @patch('os.path.exists') + @patch('shutil.copyfile') + def test_sync_data_with_snapper_helpers( + self, mock_copy, mock_exists, mock_command, + mock_sync, mock_sysconf + ): + item = {'SNAPPER_CONFIGS': '""'} + + def getitem(key): + return item[key] + + def setitem(key, value): + item[key] = value + + def contains(key): + return key in item + + def exists(name): + if 'snapper/configs/root' in name: + return False + return True + + def return_snapper_version(command=None, raise_on_error=None, *args): + snapperCmd = ['chroot', 'snapper', '--version'] + subCmd = [element for element in command if element in snapperCmd] + mock = Mock() + if snapperCmd == subCmd: + mock.output = 'snapper 0.12.1' + mock.return_code = 0 + return mock + + mock_command.side_effect = return_snapper_version + + self.volume_manager.custom_args['quota_groups'] = True + mock_exists.side_effect = exists + + sysconf = Mock() + sysconf.__contains__ = Mock(side_effect=contains) + sysconf.__getitem__ = Mock(side_effect=getitem) + sysconf.__setitem__ = Mock(side_effect=setitem) + mock_sysconf.return_value = sysconf + + self.volume_manager.toplevel_mount = Mock() + self.volume_manager.mountpoint = 'tmpdir' + self.volume_manager.custom_args['root_is_snapper_snapshot'] = True + sync = Mock() + mock_sync.return_value = sync + + self.volume_manager.sync_data(['exclude_me']) + + root_path = 'tmpdir/@/.snapshots/1/snapshot' + mock_sync.assert_called_once_with('root_dir', root_path) + mock_copy.assert_called_once_with( + root_path + '/etc/snapper/config-templates/default', + root_path + '/etc/snapper/configs/root' + ) + sync.sync_data.assert_called_once_with( + exclude=['exclude_me'], + options=[ + '--archive', '--hard-links', '--xattrs', + '--acls', '--one-file-system', '--inplace' + ] + ) + assert mock_command.call_args_list == [ + call(['chroot', 'tmpdir', 'snapper', '--version']), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir/@/.snapshots/1'], + raise_on_error=False + ), + call([ + 'mount', '-n', '--bind', 'tmpdir/@/.snapshots/1', + 'root_dir/tmpdir/@/.snapshots/1' + ]), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir/@/.snapshots/1/.snapshots'], + raise_on_error=False + ), + call([ + 'mount', '-n', '--bind', 'tmpdir/@/.snapshots/1/.snapshots', + 'root_dir/tmpdir/@/.snapshots/1/.snapshots' + ]), + call([ + 'chroot', 'root_dir', '/usr/lib/snapper/installation-helper', + '--root-prefix', 'tmpdir/@/.snapshots/1', '--step', 'config', + '--description', 'first root filesystem' + ], None, True, False, True), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir/@/.snapshots/1/.snapshots'], + raise_on_error=False + ), + call( + command=['mountpoint', '-q', 'root_dir/tmpdir/@/.snapshots/1'], + raise_on_error=False + ), + call(['btrfs', 'qgroup', 'create', '1/0', 'tmpdir']), + call([ + 'chroot', 'tmpdir/@/.snapshots/1/snapshot', 'snapper', '--no-dbus', + 'set-config', 'QGROUP=1/0' + ], None, True, False, True) ] @patch('kiwi.volume_manager.btrfs.SysConfig') @@ -502,6 +695,16 @@ def getitem(key): def contains(key): return key in item + def return_snapper_version(cmd, *args): + snapperCmd = ['chroot', 'snapper', '--version'] + subCmd = [element for element in cmd if element in snapperCmd] + if snapperCmd == subCmd: + mock = Mock() + mock.output = 'snapper 0.12.0' + return mock + + mock_command.side_effect = return_snapper_version + sysconf = Mock() sysconf.__contains__ = Mock(side_effect=contains) sysconf.__getitem__ = Mock(side_effect=getitem)