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')