diff --git a/bin/pungi-patch-iso b/bin/pungi-patch-iso new file mode 100755 index 00000000..7a83e5e2 --- /dev/null +++ b/bin/pungi-patch-iso @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This program 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; version 2 of the License. +# +# This program 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 Library General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +import argparse +import logging +import os +import sys + +here = sys.path[0] +if here != '/usr/bin': + # Git checkout + sys.path[0] = os.path.dirname(here) + +from pungi_utils import patch_iso + + +def main(args=None): + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--verbose', action='store_true', + help='Print debugging information') + parser.add_argument('--supported', choices=('true', 'false'), + help='Override supported bit on the ISO') + parser.add_argument('--volume-id', + help='Override volume ID on the ISO') + parser.add_argument('--force-arch', + help='Treat the ISO as bootable on given architecture') + parser.add_argument('target', metavar='TARGET_ISO', + help='which file to write the result to') + parser.add_argument('source', metavar='SOURCE_ISO', + help='source ISO to work with') + parser.add_argument('dir', metavar='GRAFT_DIR', + help='extra directory to graft on the ISO') + opts = parser.parse_args(args) + + level = logging.DEBUG if opts.verbose else logging.INFO + format = '%(levelname)s: %(message)s' + logging.basicConfig(level=level, format=format) + log = logging.getLogger() + + patch_iso.run(log, opts) + + +if __name__ == '__main__': + if main(): + sys.exit(1) diff --git a/pungi.spec b/pungi.spec index 5630c843..380f12a1 100644 --- a/pungi.spec +++ b/pungi.spec @@ -84,6 +84,7 @@ rm -rf %{buildroot} %{_bindir}/%{name}-create-unified-isos %{_bindir}/%{name}-config-validate %{_bindir}/%{name}-fedmsg-notification +%{_bindir}/%{name}-patch-iso %check nosetests --exe diff --git a/pungi_utils/patch_iso.py b/pungi_utils/patch_iso.py new file mode 100644 index 00000000..a0c29af3 --- /dev/null +++ b/pungi_utils/patch_iso.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- + +# This program 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; version 2 of the License. +# +# This program 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 Library General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +from kobo import shortcuts +import os +import productmd +import tempfile +try: + from shlex import quote +except ImportError: + from pipes import quote + +from pungi import util +from pungi.phases.buildinstall import tweak_configs +from pungi.wrappers import iso + + +def sh(log, cmd, *args, **kwargs): + log.info('Running: %s', ' '.join(quote(x) for x in cmd)) + ret, out = shortcuts.run(cmd, *args, **kwargs) + if out: + log.debug('%s', out) + return ret, out + + +def get_lorax_dir(default='/usr/share/lorax'): + try: + _, out = shortcuts.run(['python3', '-c' 'import pylorax; print(pylorax.find_templates())']) + return out.strip() + except Exception: + return default + + +def as_bool(arg): + if arg == 'true': + return True + elif arg == 'false': + return False + else: + return arg + + +def get_arch(log, iso_dir): + di_path = os.path.join(iso_dir, '.discinfo') + if os.path.exists(di_path): + di = productmd.discinfo.DiscInfo() + di.load(di_path) + log.info('Detected bootable ISO for %s (based on .discinfo)', di.arch) + return di.arch + + ti_path = os.path.join(iso_dir, '.treeinfo') + if os.path.exists(ti_path): + ti = productmd.treeinfo.TreeInfo() + ti.load(ti_path) + log.info('Detected bootable ISO for %s (based on .treeinfo)', ti.tree.arch) + return ti.tree.arch + + # There is no way to tell the architecture of an ISO file without guessing. + # Let's print a warning and continue with assuming unbootable ISO. + + log.warning('Failed to detect arch for ISO, assuming unbootable one.') + log.warning('If this is incorrect, use the --force-arch option.') + return None + + +def run(log, opts): + # mount source iso + log.info('Mounting %s', opts.source) + target = os.path.abspath(opts.target) + + with util.temp_dir(prefix='patch-iso-') as work_dir: + with iso.mount(opts.source) as source_iso_dir: + util.copy_all(source_iso_dir, work_dir) + + # Make everything writable + for root, dirs, files in os.walk(work_dir): + for name in files: + os.chmod(os.path.join(root, name), 0o640) + for name in dirs: + os.chmod(os.path.join(root, name), 0o755) + + # volume id is copied from source iso unless --label is specified + volume_id = opts.volume_id or iso.get_volume_id(opts.source) + + # create graft points from mounted source iso + overlay dir + graft_points = iso.get_graft_points([work_dir, opts.dir]) + # if ks.cfg is detected, patch syslinux + grub to use it + if 'ks.cfg' in graft_points: + log.info('Adding ks.cfg to boot configs') + tweak_configs(work_dir, volume_id, graft_points['ks.cfg']) + + arch = opts.force_arch or get_arch(log, work_dir) + + with tempfile.NamedTemporaryFile(prefix='graft-points-') as graft_file: + iso.write_graft_points(graft_file.name, graft_points, + exclude=["*/TRANS.TBL", "*/boot.cat"]) + + # make the target iso bootable if source iso is bootable + boot_args = input_charset = None + if arch: + boot_args = iso.get_boot_options( + arch, os.path.join(get_lorax_dir(), 'config_files/ppc')) + input_charset = 'utf-8' if 'ppc' not in arch else None + # Create the target ISO + mkisofs_cmd = iso.get_mkisofs_cmd(target, None, + volid=volume_id, + exclude=["./lost+found"], + graft_points=graft_file.name, + input_charset=input_charset, + boot_args=boot_args) + sh(log, mkisofs_cmd, workdir=work_dir) + + # isohybrid support + if arch in ["x86_64", "i386"]: + isohybrid_cmd = iso.get_isohybrid_cmd(target, arch) + sh(log, isohybrid_cmd) + + supported = as_bool(opts.supported or iso.get_checkisomd5_data(opts.source)['Supported ISO']) + # implantmd5 + supported bit (use the same as on source iso, unless + # overriden by --supported option) + isomd5sum_cmd = iso.get_implantisomd5_cmd(target, supported) + sh(log, isomd5sum_cmd) diff --git a/setup.py b/setup.py index 0badcc33..d22e61dc 100755 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ setup( 'bin/pungi-fedmsg-notification', 'bin/pungi-koji', 'bin/pungi-make-ostree', + 'bin/pungi-patch-iso', ], data_files = [ ('/usr/share/pungi', glob.glob('share/*.xsl')), diff --git a/tests/test_patch_iso.py b/tests/test_patch_iso.py new file mode 100644 index 00000000..c108a8d1 --- /dev/null +++ b/tests/test_patch_iso.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- + +import mock +import os +import sys +try: + import unittest2 as unittest +except ImportError: + import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from tests.helpers import boom, touch, copy_fixture +from pungi_utils import patch_iso + + +class TestUnifiedIsos(unittest.TestCase): + pass + + +class TestGetLoraxDir(unittest.TestCase): + @mock.patch('kobo.shortcuts.run') + def test_success(self, mock_run): + mock_run.return_value = (0, 'hello') + self.assertEqual(patch_iso.get_lorax_dir(None), 'hello') + self.assertEqual(1, len(mock_run.call_args_list)) + + @mock.patch('kobo.shortcuts.run') + def test_crash(self, mock_run): + mock_run.side_effect = boom + self.assertEqual(patch_iso.get_lorax_dir('hello'), 'hello') + self.assertEqual(1, len(mock_run.call_args_list)) + + +class TestSh(unittest.TestCase): + @mock.patch('kobo.shortcuts.run') + def test_cmd(self, mock_run): + mock_run.return_value = (0, 'ok') + log = mock.Mock() + patch_iso.sh(log, ['ls'], foo='bar') + self.assertEqual(mock_run.call_args_list, + [mock.call(['ls'], foo='bar')]) + self.assertEqual(log.info.call_args_list, + [mock.call('Running: %s', 'ls')]) + self.assertEqual(log.debug.call_args_list, + [mock.call('%s', 'ok')]) + + +class TestAsBool(unittest.TestCase): + def test_true(self): + self.assertTrue(patch_iso.as_bool('true')) + + def test_false(self): + self.assertFalse(patch_iso.as_bool('false')) + + def test_anything_else(self): + obj = mock.Mock() + self.assertIs(patch_iso.as_bool(obj), obj) + + +class EqualsAny(object): + def __eq__(self, another): + return True + + def __repr__(self): + return u'ANYTHING' + +ANYTHING = EqualsAny() + + +class TestPatchingIso(unittest.TestCase): + + @mock.patch('pungi_utils.patch_iso.util.copy_all') + @mock.patch('pungi_utils.patch_iso.iso') + @mock.patch('pungi_utils.patch_iso.sh') + def test_whole(self, sh, iso, copy_all): + iso.mount.return_value.__enter__.return_value = 'mounted-iso-dir' + + def _create_files(src, dest): + touch(os.path.join(dest, 'dir', 'file.txt'), 'Hello') + + copy_all.side_effect = _create_files + + log = mock.Mock(name='logger') + opts = mock.Mock( + target='test.iso', + source='source.iso', + force_arch=None, + volume_id='FOOBAR', + ) + patch_iso.run(log, opts) + + self.assertEqual(iso.get_mkisofs_cmd.call_args_list, + [mock.call(os.path.abspath(opts.target), None, + boot_args=None, + exclude=['./lost+found'], + graft_points=ANYTHING, + input_charset=None, + volid='FOOBAR')]) + self.assertEqual(iso.mount.call_args_list, + [mock.call('source.iso')]) + self.assertEqual(copy_all.mock_calls, + [mock.call('mounted-iso-dir', ANYTHING)]) + self.assertEqual( + sh.call_args_list, + [mock.call(log, iso.get_mkisofs_cmd.return_value, workdir=ANYTHING), + mock.call(log, iso.get_implantisomd5_cmd.return_value)]) + + @mock.patch('pungi_utils.patch_iso.util.copy_all') + @mock.patch('pungi_utils.patch_iso.iso') + @mock.patch('pungi_utils.patch_iso.sh') + def test_detect_arch_discinfo(self, sh, iso, copy_all): + iso.mount.return_value.__enter__.return_value = 'mounted-iso-dir' + + def _create_files(src, dest): + touch(os.path.join(dest, 'dir', 'file.txt'), 'Hello') + touch(os.path.join(dest, '.discinfo'), + '1487578537.111417\nDummy Product 1.0\nppc64\n1') + + copy_all.side_effect = _create_files + + log = mock.Mock(name='logger') + opts = mock.Mock( + target='test.iso', + source='source.iso', + force_arch=None, + volume_id=None + ) + patch_iso.run(log, opts) + + self.assertEqual(iso.mount.call_args_list, + [mock.call('source.iso')]) + self.assertEqual(iso.get_mkisofs_cmd.call_args_list, + [mock.call(os.path.abspath(opts.target), None, + boot_args=iso.get_boot_options.return_value, + exclude=['./lost+found'], + graft_points=ANYTHING, + input_charset=None, + volid=iso.get_volume_id.return_value)]) + self.assertEqual(copy_all.mock_calls, + [mock.call('mounted-iso-dir', ANYTHING)]) + self.assertEqual( + sh.call_args_list, + [mock.call(log, iso.get_mkisofs_cmd.return_value, workdir=ANYTHING), + mock.call(log, iso.get_implantisomd5_cmd.return_value)]) + + @mock.patch('pungi_utils.patch_iso.util.copy_all') + @mock.patch('pungi_utils.patch_iso.iso') + @mock.patch('pungi_utils.patch_iso.sh') + def test_run_isohybrid(self, sh, iso, copy_all): + iso.mount.return_value.__enter__.return_value = 'mounted-iso-dir' + + def _create_files(src, dest): + touch(os.path.join(dest, 'dir', 'file.txt'), 'Hello') + copy_fixture( + 'DP-1.0-20161013.t.4/compose/Server/x86_64/os/.treeinfo', + os.path.join(dest, '.treeinfo') + ) + + copy_all.side_effect = _create_files + + log = mock.Mock(name='logger') + opts = mock.Mock( + target='test.iso', + source='source.iso', + force_arch=None, + volume_id=None + ) + patch_iso.run(log, opts) + + self.assertEqual(iso.mount.call_args_list, + [mock.call('source.iso')]) + self.assertEqual(iso.get_mkisofs_cmd.call_args_list, + [mock.call(os.path.abspath(opts.target), None, + boot_args=iso.get_boot_options.return_value, + exclude=['./lost+found'], + graft_points=ANYTHING, + input_charset='utf-8', + volid=iso.get_volume_id.return_value)]) + self.assertEqual(copy_all.mock_calls, + [mock.call('mounted-iso-dir', ANYTHING)]) + self.assertEqual( + sh.call_args_list, + [mock.call(log, iso.get_mkisofs_cmd.return_value, workdir=ANYTHING), + mock.call(log, iso.get_isohybrid_cmd.return_value), + mock.call(log, iso.get_implantisomd5_cmd.return_value)]) + + @mock.patch('pungi_utils.patch_iso.tweak_configs') + @mock.patch('pungi_utils.patch_iso.util.copy_all') + @mock.patch('pungi_utils.patch_iso.iso') + @mock.patch('pungi_utils.patch_iso.sh') + def test_add_ks_cfg(self, sh, iso, copy_all, tweak_configs): + iso.mount.return_value.__enter__.return_value = 'mounted-iso-dir' + iso.get_graft_points.return_value = { + 'ks.cfg': 'path/to/ks.cfg', + } + + def _create_files(src, dest): + touch(os.path.join(dest, 'dir', 'file.txt'), 'Hello') + + copy_all.side_effect = _create_files + + log = mock.Mock(name='logger') + opts = mock.Mock( + target='test.iso', + source='source.iso', + force_arch='s390', + volume_id='foobar', + ) + patch_iso.run(log, opts) + + self.assertEqual(iso.mount.call_args_list, + [mock.call('source.iso')]) + self.assertEqual(iso.get_mkisofs_cmd.call_args_list, + [mock.call(os.path.abspath(opts.target), None, + boot_args=iso.get_boot_options.return_value, + exclude=['./lost+found'], + graft_points=ANYTHING, + input_charset='utf-8', + volid='foobar')]) + self.assertEqual(tweak_configs.call_args_list, + [mock.call(ANYTHING, 'foobar', 'path/to/ks.cfg')]) + self.assertEqual(copy_all.mock_calls, + [mock.call('mounted-iso-dir', ANYTHING)]) + self.assertEqual( + sh.call_args_list, + [mock.call(log, iso.get_mkisofs_cmd.return_value, workdir=ANYTHING), + mock.call(log, iso.get_implantisomd5_cmd.return_value)])