diff --git a/kiwi_stackbuild_plugin/cli.py b/kiwi_stackbuild_plugin/cli.py new file mode 100644 index 0000000..a701776 --- /dev/null +++ b/kiwi_stackbuild_plugin/cli.py @@ -0,0 +1,171 @@ +# Copyright (c) 2025 SUSE LLC. All rights reserved. +# +# This file is part of kiwi-stackbuild. +# +# kiwi-stackbuild is free software: you can redistribute it and/or modify +# it under the terms owf 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-stackbuild 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-stackbuild. If not, see +# +import typer +import itertools +from pathlib import Path +from typing import ( + Annotated, Optional, List, Union, no_type_check +) + +typers = { + 'stackbuild': typer.Typer( + add_completion=False + ), + 'stash': typer.Typer( + add_completion=False + ) +} + +system_stackbuild = typers['stackbuild'] +system_stash = typers['stash'] + + +@no_type_check +@system_stackbuild.command( + context_settings={ + 'allow_extra_args': True, + 'ignore_unknown_options': True + } +) +def kiwi( + ctx: typer.Context +): + """ + List of command parameters as supported by the kiwi-ng + build or create command. The information given here is passed + along to the kiwi-ng system build or the kiwi-ng system create + command depending on the presence of the --description + option. + """ + Cli = ctx.obj + args = ctx.args + for option in list(set(args)): + if type(option) is not str or not option.startswith('-'): + continue + k: List[Union[str, List]] = [option] + v = [] + indexes = [n for n, x in enumerate(args) if x == option] + if len(indexes) > 1: + for index in indexes: + v.append(args[index + 1]) + for index in sorted(indexes, reverse=True): + del args[index + 1] + del args[index] + k.append(v) + args += k + Cli.subcommand_args['stackbuild']['system_build_or_create'] = \ + dict(itertools.zip_longest(*[iter(args)] * 2)) + Cli.global_args['command'] = 'stackbuild' + Cli.global_args['system'] = True + Cli.cli_ok = True + + +@system_stackbuild.callback( + help='Build an image based on a given stash container root. ' + 'If no KIWI --description parameter is provided, ' + 'stackbuild rebuilds the image from the stash container. ' + 'In this case, the given kiwi parameters are passed to the ' + 'kiwi-ng system create command. If a KIWI description is ' + 'provided, this description takes over precedence and a new ' + 'image from this description based on the given stash container ' + 'root will be built. In this case, the given kiwi parameters ' + 'are passed to the kiwi-ng system build command.', + invoke_without_command=False, + subcommand_metavar='kiwi [OPTIONS]' +) +def stackbuild( + ctx: typer.Context, + stash: Annotated[ + List[str], typer.Option( + help=' Name of the stash container. See system stash --list ' + 'for available stashes. Multiple --stash options will be stacked ' + 'together in the given order' + ) + ], + target_dir: Annotated[ + Path, typer.Option( + help=' The target directory to store the ' + 'system image file(s)' + ) + ], + description: Annotated[ + Optional[Path], typer.Option( + help=' Path to KIWI image description' + ) + ] = None, + from_registry: Annotated[ + Optional[str], typer.Option( + help=' Pull given stash container name from the ' + 'provided registry URI' + ) + ] = None, +): + Cli = ctx.obj + Cli.subcommand_args['stackbuild'] = { + '--stash': stash, + '--target-dir': target_dir, + '--description': description, + '--from-registry': from_registry, + 'help': False + } + + +@system_stash.callback( + help='Create a container from the given root directory', + invoke_without_command=True, + subcommand_metavar='' +) +def stash( + ctx: typer.Context, + root: Annotated[ + Optional[Path], typer.Option( + help=' The path to the root directory, ' + 'usually the result of a former system prepare or ' + 'build call' + ) + ] = None, + tag: Annotated[ + Optional[str], typer.Option( + help=' The tag name for the container. ' + 'By default set to: latest' + ) + ] = None, + container_name: Annotated[ + Optional[str], typer.Option( + help=' The name of the container. By default ' + 'set to the image name of the stash' + ) + ] = None, + stash_list: Annotated[ + Optional[bool], typer.Option( + '--list', + help='List the available stashes' + ) + ] = False +): + Cli = ctx.obj + Cli.subcommand_args['stash'] = { + '--root': root, + '--tag': tag, + '--container-name': container_name, + '--list': stash_list, + 'help': False + } + Cli.global_args['command'] = 'stash' + Cli.global_args['system'] = True + Cli.cli_ok = True diff --git a/kiwi_stackbuild_plugin/tasks/system_stackbuild.py b/kiwi_stackbuild_plugin/tasks/system_stackbuild.py index 140b4c0..55e4b77 100644 --- a/kiwi_stackbuild_plugin/tasks/system_stackbuild.py +++ b/kiwi_stackbuild_plugin/tasks/system_stackbuild.py @@ -182,23 +182,28 @@ def process(self) -> None: def _validate_kiwi_create_command( self, kiwi_create_command: List[str] ) -> List[str]: - # construct create command from given command line - kiwi_create_command += self.command_args.get( - '' - ) - if '--' in kiwi_create_command: - kiwi_create_command.remove('--') - # validate create command through docopt from the original - # kiwi.tasks.system_create docopt information - log.debug( - 'Validating kiwi_create_command_args:{0} {1}'.format( - os.linesep, kiwi_create_command + if self.command_args.get(''): + # construct create command from docopt command line + kiwi_create_command += self.command_args.get( + '' ) - ) - validated_create_command = docopt( - kiwi.tasks.system_create.__doc__, - argv=kiwi_create_command - ) + if '--' in kiwi_create_command: + kiwi_create_command.remove('--') + # validate create command through docopt from the original + # kiwi.tasks.system_create docopt information + log.debug( + 'Validating kiwi_create_command_args:{0} {1}'.format( + os.linesep, kiwi_create_command + ) + ) + validated_create_command = docopt( + kiwi.tasks.system_create.__doc__, + argv=kiwi_create_command + ) + else: + validated_create_command = \ + self.command_args.get('system_build_or_create') + # rebuild kiwi create command from validated docopt parser result return self._rebuild_kiwi_command( validated_create_command, 'create' @@ -207,24 +212,29 @@ def _validate_kiwi_create_command( def _validate_kiwi_build_command( self, kiwi_build_command: List[str] ) -> List[str]: - # construct build command from given command line - kiwi_build_command += self.command_args.get( - '' - ) - if '--' in kiwi_build_command: - kiwi_build_command.remove('--') - # validate build command through docopt from the original - # kiwi.tasks.system_build docopt information - log.debug( - 'Validating kiwi_build_command_args:{0} {1}'.format( - os.linesep, kiwi_build_command + if self.command_args.get(''): + # construct build command from given command line + kiwi_build_command += self.command_args.get( + '' ) - ) - validated_build_command = docopt( - kiwi.tasks.system_build.__doc__, - argv=kiwi_build_command - ) - # rebuild kiwi build command from validated docopt parser result + if '--' in kiwi_build_command: + kiwi_build_command.remove('--') + # validate build command through docopt from the original + # kiwi.tasks.system_build docopt information + log.debug( + 'Validating kiwi_build_command_args:{0} {1}'.format( + os.linesep, kiwi_build_command + ) + ) + validated_build_command = docopt( + kiwi.tasks.system_build.__doc__, + argv=kiwi_build_command + ) + else: + validated_build_command = \ + self.command_args.get('system_build_or_create') + + # rebuild kiwi build command from validated parser result return self._rebuild_kiwi_command( validated_build_command, 'build' ) diff --git a/kiwi_stackbuild_plugin/tasks/system_stash.py b/kiwi_stackbuild_plugin/tasks/system_stash.py index 122de20..18755ec 100644 --- a/kiwi_stackbuild_plugin/tasks/system_stash.py +++ b/kiwi_stackbuild_plugin/tasks/system_stash.py @@ -81,80 +81,81 @@ def process(self) -> None: stashes.display() return - Privileges.check_for_root_permissions() + if self.command_args['--root']: + Privileges.check_for_root_permissions() - log.info('Reading Image description') - kiwi_description = os.path.join( - self.command_args['--root'], 'image', 'config.xml' - ) - description = XMLDescription(kiwi_description) - xml_state = XMLState( - xml_data=description.load() - ) - contact_info = xml_state.get_description_section() - image_name = self.command_args['--container-name'] or \ - xml_state.xml_data.get_name() - if not StackBuildDefaults.is_container_name_valid(image_name): - message = dedent('''\n - Image name {0!r} cannot be used as container name - - Container names must start or end with a letter or number, - and can contain only letters, numbers, and the dash (-) - character. The name attribute in the KIWI description at: - {1} - does not conform to this requirement - - Please provide an alternative stash name via the - - --container-name= - - option - ''') - raise KiwiStackBuildPluginContainerNameInvalid( - message.format( - image_name, kiwi_description + log.info('Reading Image description') + kiwi_description = os.path.join( + self.command_args['--root'], 'image', 'config.xml' + ) + description = XMLDescription(kiwi_description) + xml_state = XMLState( + xml_data=description.load() + ) + contact_info = xml_state.get_description_section() + image_name = self.command_args['--container-name'] or \ + xml_state.xml_data.get_name() + if not StackBuildDefaults.is_container_name_valid(image_name): + message = dedent('''\n + Image name {0!r} cannot be used as container name + + Container names must start or end with a letter or number, + and can contain only letters, numbers, and the dash (-) + character. The name attribute in the KIWI description at: + {1} + does not conform to this requirement + + Please provide an alternative stash name via the + + --container-name= + + option + ''') + raise KiwiStackBuildPluginContainerNameInvalid( + message.format( + image_name, kiwi_description + ) ) + stash_target_dir = SystemStashTask._create_stash_target_dir( + image_name ) - stash_target_dir = SystemStashTask._create_stash_target_dir( - image_name - ) - stash_container_file_name = os.path.join( - stash_target_dir, f'{image_name}.tar' - ) - stash_container_tag = self.command_args['--tag'] or 'latest' - container_config = StackBuildDefaults.get_container_config( - image_name, stash_container_tag, contact_info.author - ) - log.info('Initializing stash container') - oci = OCI.new() - if os.path.isfile(stash_container_file_name): - log.info('--> Adding new layer on existing stash') - oci.import_container_image( - f'oci-archive:{stash_container_file_name}:' + stash_container_file_name = os.path.join( + stash_target_dir, f'{image_name}.tar' + ) + stash_container_tag = self.command_args['--tag'] or 'latest' + container_config = StackBuildDefaults.get_container_config( + image_name, stash_container_tag, contact_info.author + ) + log.info('Initializing stash container') + oci = OCI.new() + if os.path.isfile(stash_container_file_name): + log.info('--> Adding new layer on existing stash') + oci.import_container_image( + f'oci-archive:{stash_container_file_name}:' + f'{image_name}:{stash_container_tag}' + ) + else: + log.info('--> Creating initial layer') + oci.init_container() + + oci.unpack() + oci.sync_rootfs( + self.command_args['--root'], + StackBuildDefaults.get_stash_exclude_list() + ) + oci.repack(container_config) + oci.set_config(container_config) + oci.post_process() + log.info('Exporting stash container') + oci.export_container_image( + stash_container_file_name, 'oci-archive', f'{image_name}:{stash_container_tag}' ) - else: - log.info('--> Creating initial layer') - oci.init_container() - - oci.unpack() - oci.sync_rootfs( - self.command_args['--root'], - StackBuildDefaults.get_stash_exclude_list() - ) - oci.repack(container_config) - oci.set_config(container_config) - oci.post_process() - log.info('Exporting stash container') - oci.export_container_image( - stash_container_file_name, 'oci-archive', - f'{image_name}:{stash_container_tag}' - ) - log.info('Importing stash to local registry') - Command.run( - ['podman', 'load', '-i', stash_container_file_name] - ) + log.info('Importing stash to local registry') + Command.run( + ['podman', 'load', '-i', stash_container_file_name] + ) @staticmethod def _create_stash_target_dir(image_name: str) -> str: diff --git a/package/python-kiwi_stackbuild_plugin-spec-template b/package/python-kiwi_stackbuild_plugin-spec-template index 719bde9..646820d 100644 --- a/package/python-kiwi_stackbuild_plugin-spec-template +++ b/package/python-kiwi_stackbuild_plugin-spec-template @@ -105,7 +105,12 @@ image root directory Summary: KIWI - Stack Build Plugin Group: Development/Languages/Python Requires: python%{python3_pkgversion} >= 3.9 -Requires: python%{python3_pkgversion}-docopt +%if ! (0%{?fedora} >= 41 || 0%{?rhel} >= 10) +Requires: python%{python3_pkgversion}-docopt >= 0.6.2 +%else +Requires: python%{python3_pkgversion}-docopt-ng +%endif +Recommends: python%{python3_pkgversion}-typer >= 0.9.0 Requires: python%{python3_pkgversion}-kiwi >= 9.21.21 %description -n python%{python3_pkgversion}-kiwi_stackbuild_plugin diff --git a/pyproject.toml b/pyproject.toml index c584311..ab11ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ system_stash = "kiwi_stackbuild_plugin.tasks.system_stash" [tool.poetry.group.test] [tool.poetry.group.test.dependencies] +# for local plugin cli testing +typer = ">=0.9.0" # python unit testing framework pytest = ">=6.2.0" pytest-cov = "*" diff --git a/test/unit/.coveragerc b/test/unit/.coveragerc index 27bb669..b9ed032 100644 --- a/test/unit/.coveragerc +++ b/test/unit/.coveragerc @@ -1,7 +1,9 @@ [run] omit = */version.py + */cli.py [report] omit = */version.py + */cli.py diff --git a/test/unit/tasks/system_stackbuild_test.py b/test/unit/tasks/system_stackbuild_test.py index f78b693..137a0a7 100644 --- a/test/unit/tasks/system_stackbuild_test.py +++ b/test/unit/tasks/system_stackbuild_test.py @@ -87,6 +87,65 @@ def test_process_root_sync_failed( with raises(KiwiStackBuildPluginRootSyncFailed): self.task.process() + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Privileges') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Path.create') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Command.run') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.SystemCreateTask') + @patch('os.path.exists') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.patch.object') + def test_process_rebuild_typer_commandline( + self, mock_patch_object, mock_os_path_exists, + mock_SystemCreateTask, mock_Command_run, + mock_Path_create, mock_Privileges + ): + self._init_command_args() + self.task.command_args[''] = None + self.task.command_args[''] = None + self.task.command_args['system_build_or_create'] = { + '--root': '/some/target-dir/build/image-root', + '--target-dir': '/some/target-dir', + '--signing-key': 'some-key' + } + self.task.command_args['stackbuild'] = True + self.task.command_args['--stash'] = ['name'] + self.task.command_args['--target-dir'] = '/some/target-dir' + self.task.command_args['--from-registry'] = 'registry.uri' + mock_os_path_exists.return_value = False + mock_Command_run.return_value.output = '/podman/mount/path' + kiwi_task = Mock() + mock_SystemCreateTask.return_value = kiwi_task + self.task.process() + assert mock_Command_run.call_args_list == [ + call(['podman', 'pull', 'registry.uri/name']), + call(['podman', 'image', 'mount', 'name']), + call( + [ + 'rsync', '--archive', '--hard-links', '--xattrs', + '--acls', '--one-file-system', '--inplace', + '/podman/mount/path/', + '/some/target-dir/build/image-root' + ] + ), + call( + ['podman', 'image', 'umount', '--force', 'name'], + raise_on_error=False + ) + ] + mock_SystemCreateTask.assert_called_once_with( + should_perform_task_setup=False + ) + kiwi_task.process.assert_called_once_with() + mock_patch_object.assert_called_once_with( + sys, 'argv', [ + 'kiwi-ng', '--type', 'iso', + '--profile', 'a', '--profile', 'b', + 'system', 'create', + '--root', '/some/target-dir/build/image-root', + '--target-dir', '/some/target-dir', + '--signing-key', 'some-key' + ] + ) + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Privileges') @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Path.create') @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Command.run') @@ -139,6 +198,67 @@ def test_process_rebuild( ] ) + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Privileges') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Path.create') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Command.run') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.SystemBuildTask') + @patch('os.path.exists') + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.patch.object') + def test_process_new_build_typer_commandline( + self, mock_patch_object, mock_os_path_exists, + mock_SystemBuildTask, mock_Command_run, + mock_Path_create, mock_Privileges + ): + self._init_command_args() + self.task.command_args[''] = None + self.task.command_args[''] = None + self.task.command_args['system_build_or_create'] = { + '--description': '/path/to/kiwi/description', + '--target-dir': '/some/target-dir', + '--allow-existing-root': True, + '--signing-key': 'some-key' + } + self.task.command_args['stackbuild'] = True + self.task.command_args['--stash'] = ['name'] + self.task.command_args['--target-dir'] = '/some/target-dir' + self.task.command_args['--description'] = '/path/to/kiwi/description' + self.task.command_args['--from-registry'] = 'registry.uri' + mock_os_path_exists.return_value = False + mock_Command_run.return_value.output = '/podman/mount/path' + kiwi_task = Mock() + mock_SystemBuildTask.return_value = kiwi_task + self.task.process() + assert mock_Command_run.call_args_list == [ + call(['podman', 'pull', 'registry.uri/name']), + call(['podman', 'image', 'mount', 'name']), + call( + [ + 'rsync', '--archive', '--hard-links', '--xattrs', + '--acls', '--one-file-system', '--inplace', + '/podman/mount/path/', + '/some/target-dir/build/image-root' + ] + ), + call( + ['podman', 'image', 'umount', '--force', 'name'], + raise_on_error=False + ) + ] + mock_SystemBuildTask.assert_called_once_with( + should_perform_task_setup=False + ) + kiwi_task.process.assert_called_once_with() + mock_patch_object.assert_called_once_with( + sys, 'argv', [ + 'kiwi-ng', '--type', 'iso', + '--profile', 'a', '--profile', 'b', + 'system', 'build', + '--description', '/path/to/kiwi/description', + '--target-dir', '/some/target-dir', + '--allow-existing-root', '--signing-key', 'some-key' + ] + ) + @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Privileges') @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Path.create') @patch('kiwi_stackbuild_plugin.tasks.system_stackbuild.Command.run')