From 88107398f5495b30b907cab7e6aacc9ff46ff316 Mon Sep 17 00:00:00 2001 From: Rhys Oxenham Date: Tue, 2 Dec 2025 16:59:46 +0000 Subject: [PATCH] Adding support for import of custom certificates This commit adds support for providing custom CA certificates during the build process. It allows a user to specify one ore more CA file(s) via the new section and/or via the command line. The certificate files will be imported directly after the image bootstrap has finished (where the required CA update tools are available), but before any further packages are retrieved, solving for situations where the chroot environment needs certificates, e.g. when there's a proxy server in the build environment. Co-authored-by: Marcus Schaefer --- .../test-image-disk-simple/appliance.kiwi | 3 + doc/source/commands/system_build.rst | 20 ++ doc/source/commands/system_prepare.rst | 20 ++ doc/source/image_description/elements.rst | 43 +++- kiwi/defaults.py | 28 +++ kiwi/exceptions.py | 7 + kiwi/schema/kiwi.rnc | 34 ++++ kiwi/schema/kiwi.rng | 59 ++++++ kiwi/system/prepare.py | 66 ++++++ kiwi/tasks/system_build.py | 40 ++++ kiwi/tasks/system_prepare.py | 40 ++++ kiwi/xml_parse.py | 191 +++++++++++++++++- kiwi/xml_state.py | 54 +++++ test/data/example_config.xml | 3 + test/unit/cli_test.py | 2 + test/unit/system/prepare_test.py | 70 +++++++ test/unit/tasks/system_build_test.py | 30 ++- test/unit/tasks/system_prepare_test.py | 29 ++- test/unit/xml_state_test.py | 18 ++ 19 files changed, 749 insertions(+), 8 deletions(-) diff --git a/build-tests/x86/tumbleweed/test-image-disk-simple/appliance.kiwi b/build-tests/x86/tumbleweed/test-image-disk-simple/appliance.kiwi index 531a7bd920f..7587adb7d71 100644 --- a/build-tests/x86/tumbleweed/test-image-disk-simple/appliance.kiwi +++ b/build-tests/x86/tumbleweed/test-image-disk-simple/appliance.kiwi @@ -30,6 +30,9 @@ + + + diff --git a/doc/source/commands/system_build.rst b/doc/source/commands/system_build.rst index 131c2012c2b..89faf60bda6 100644 --- a/doc/source/commands/system_build.rst +++ b/doc/source/commands/system_build.rst @@ -24,6 +24,8 @@ SYNOPSIS [--add-repo-credentials=...] [--add-package=...] [--add-bootstrap-package=...] + [--ca-cert=...] + [--ca-target-distribution=] [--delete-package=...] [--set-container-derived-from=] [--set-container-tag=] @@ -98,6 +100,24 @@ OPTIONS is shared between multiple image builds on that host for performance reasons. +--ca-cert= + + Add a cert-file to the directory storing additional local CA certificates. + The import will occur immediately after the bootstrap process, where + the required CA update tooling is expected to be installed. This + option is useful for situations where certificates are not packaged, + or the certificates are required during the build process, e.g. due + to proxy servers in the build environment that need certificates + in chroot. The option can be specified multiple times. + +--ca-target-distribution= + + Specify target distribution for the import of certificates + via the --ca-cert options(s) and/or the provided + from the image description. The selected distribution is used + in KIWI to map the distribution specific CA storage path and + update tool for the import process. + --delete-package= Specify package to delete. The option can be specified diff --git a/doc/source/commands/system_prepare.rst b/doc/source/commands/system_prepare.rst index c5c1ee30a13..b8ba9ac0b19 100644 --- a/doc/source/commands/system_prepare.rst +++ b/doc/source/commands/system_prepare.rst @@ -22,6 +22,8 @@ SYNOPSIS [--add-repo-credentials=...] [--add-package=...] [--add-bootstrap-package=...] + [--ca-cert=...] + [--ca-target-distribution=] [--delete-package=...] [--set-container-derived-from=] [--set-container-tag=] @@ -95,6 +97,24 @@ OPTIONS is shared between multiple image builds on the host for performance reasons. +--ca-cert= + + Add a cert-file to the directory storing additional local CA certificates. + The import will occur immediately after the bootstrap process, where + the required CA update tooling is expected to be installed. This + option is useful for situations where certificates are not packaged, + or the certificates are required during the build process, e.g. due + to proxy servers in the build environment that need certificates + in chroot. The option can be specified multiple times. + +--ca-target-distribution= + + Specify target distribution for the import of certificates + via the --ca-cert options(s) and/or the provided + from the image description. The selected distribution is used + in KIWI to map the distribution specific CA storage path and + update tool for the import process. + --delete-package= Specify a package to delete. The option can be specified diff --git a/doc/source/image_description/elements.rst b/doc/source/image_description/elements.rst index cbf74e4186c..6f5878f20e5 100644 --- a/doc/source/image_description/elements.rst +++ b/doc/source/image_description/elements.rst @@ -120,6 +120,41 @@ The following optional sub sections can be inserted below the description tag: license Specifies the license name which applies to this image description. +.. _sec.certificates: + + +-------------- + +Add a cert-file to the directory storing additional local CA certificates. +The import will occur immediately after the bootstrap process, where +the required CA update tooling is expected to be installed. This +setting is useful for situations where certificates are not packaged, +or the certificates are required during the build process, e.g. due +to proxy servers in the build environment that need certificates +in chroot. The required `target_distribution` attribute must be set +to allow kiwi a correct matching for the CA store path and the update +tool with regards to the image target distribution. The following +settings apply: + ++--------------+-------------------------------------------+------------------------+ +| Distributor | CA Store | Update Tool | ++==============+===========================================+========================+ +| SUSE | /etc/pki/trust/anchors | update-ca-certificates | ++--------------+-------------------------------------------+------------------------+ +| Red Hat | /etc/pki/ca-trust/source/anchors | update-ca-certificates | ++--------------+-------------------------------------------+------------------------+ +| Debian Based | /usr/local/share/ca-certificates | update-ca-certificates | ++--------------+-------------------------------------------+------------------------+ +| Arch Linux | /etc/ca-certificates/trust-source/anchors | update-ca-trust | ++--------------+-------------------------------------------+------------------------+ + +.. code:: xml + + + + + + .. _sec.preferences: @@ -170,7 +205,7 @@ table shows which package manager is connected to which distributor: +==============+=================+ | SUSE | zypper | +--------------+-----------------+ -| RedHat | dnf4 / dnf5 | +| Red Hat | dnf4 / dnf5 | +--------------+-----------------+ | Debian Based | apt | +--------------+-----------------+ @@ -1706,7 +1741,7 @@ The namedCollection element is used to install a number of packages grouped together under a name. This is a feature of the individual distribution and used in the implementation of the {kiwi} package manager backend. At the moment collections are only supported for -SUSE and RedHat based distributions. The optional `patternType` attribute +SUSE and Red Hat based distributions. The optional `patternType` attribute is used to control the behavior of the dependency resolution of the package collection. `onlyRequired` installs only the collection and its required packages. `plusRecommended` installs the collection, @@ -1724,9 +1759,9 @@ any of its required packages and any recommended packages. `$ zypper search patterns`. By convention all packages that starts with the name "patterns-" are representing a pattern package. -.. note:: Collections on RedHat +.. note:: Collections on Red Hat - On RedHat based distributions collections are called `groups` and are + On Red Hat based distributions collections are called `groups` and are extra metadata. To get the names of these groups type the following command: `$ dnf group list -v`. Please note that since {kiwi} v9.23.39, group IDs are allowed only, e.g.: diff --git a/kiwi/defaults.py b/kiwi/defaults.py index ddb39806566..c069a3bdde8 100644 --- a/kiwi/defaults.py +++ b/kiwi/defaults.py @@ -100,6 +100,26 @@ # optional package manager environment variables PACKAGE_MANAGER_ENV_VARS = '/.kiwi.package_manager.env' +# Distribution specific CA store and tooling +CA_UPDATE_MAP = { + 'suse': { + 'tool': 'update-ca-certificates', + 'destination_path': '/etc/pki/trust/anchors' + }, + 'rhel': { + 'tool': 'update-ca-certificates', + 'destination_path': '/etc/pki/ca-trust/source/anchors/' + }, + 'debian': { + 'tool': 'update-ca-certificates', + 'destination_path': '/usr/local/share/ca-certificates/' + }, + 'archlinux': { + 'tool': 'update-ca-trust', + 'destination_path': '/etc/ca-certificates/trust-source/anchors/' + } +} + log = logging.getLogger('kiwi') @@ -2316,6 +2336,14 @@ def get_apk_repo_config() -> str: """ return '/etc/apk/repositories' + @staticmethod + def get_ca_update_map(target_distribution) -> Optional[Dict[str, str]]: + return CA_UPDATE_MAP.get(target_distribution) + + @staticmethod + def get_ca_target_distributions() -> List[str]: + return sorted(CA_UPDATE_MAP.keys()) + def get(self, key): """ Implements get method for profile elements diff --git a/kiwi/exceptions.py b/kiwi/exceptions.py index 7f91736d355..d1494c7eb3f 100644 --- a/kiwi/exceptions.py +++ b/kiwi/exceptions.py @@ -874,3 +874,10 @@ class KiwiEnclaveFormatError(KiwiError): Exception raised if no enclave_format attribute specified for the selected build type """ + + +class KiwiCATargetDistributionError(KiwiError): + """ + Exception raised if no CA target distribution can be found + but the request to import custom CA certificates was issued + """ diff --git a/kiwi/schema/kiwi.rnc b/kiwi/schema/kiwi.rnc index 08734bc1cd1..6f643793743 100644 --- a/kiwi/schema/kiwi.rnc +++ b/kiwi/schema/kiwi.rnc @@ -84,6 +84,7 @@ div { element image { k.image.attlist & k.include* & + k.certificates? & k.description & k.preferences+ & k.profiles* & @@ -1121,6 +1122,39 @@ div { } } +#========================================== +# common element +# +div { + k.certificates.profiles.attribute = k.profiles.attribute + k.certificates.target_distribution.attribute = + attribute target_distribution { + "suse" | "rhel" | "debian" | "archlinux" + } + k.certificates.attlist = + k.certificates.profiles.attribute? & + k.certificates.target_distribution.attribute + k.certificates = + element certificates { + k.certificates.attlist, + k.certificate+ + } +} + +#========================================== +# common element +# +div { + k.certificate.name.attribute = k.name.attribute + k.certificate.attlist = k.certificate.name.attribute + k.certificate = + # A pointer to a filename handled as a CA file + element certificate { + k.certificate.attlist, + empty + } +} + #========================================== # common element # diff --git a/kiwi/schema/kiwi.rng b/kiwi/schema/kiwi.rng index 9ee0a18b1ea..dfd0147fb17 100644 --- a/kiwi/schema/kiwi.rng +++ b/kiwi/schema/kiwi.rng @@ -194,6 +194,9 @@ named /etc/ImageID + + + @@ -1736,6 +1739,62 @@ loading of the container at first boot + +
+ + + + + + + suse + rhel + debian + archlinux + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
Syncing: {cert_file} to {ca_chroot_path}' + ) + DataSync(cert_file, ca_chroot_path).sync_data() + has_new_certs = True + else: + log.warning( + f'--> Skipping: {cert_file}: ' + 'does not exist or is directory' + ) + if has_new_certs: + ca_tool = ca_update_info['tool'] + log.info( + f'--> Updating CA certificate store with: {ca_tool}' + ) + Command.run( + ['chroot', self.root_dir, ca_tool] + ) + except Exception as issue: + raise KiwiBootStrapPhaseFailed( + self.issue_message.format( + headline='Failed to setup custom CA certificates', + reason=issue + ) + ) + else: + log.warning( + 'Could not determine CA update tool, skipping setup' + ) + def _install_archives(self, archive_list, archive_target_dir_dict): log.info("Installing archives") for archive in archive_list: diff --git a/kiwi/tasks/system_build.py b/kiwi/tasks/system_build.py index a42aa463d7f..755b1cbd029 100644 --- a/kiwi/tasks/system_build.py +++ b/kiwi/tasks/system_build.py @@ -28,6 +28,8 @@ [--add-repo-credentials=...] [--add-package=...] [--add-bootstrap-package=...] + [--ca-cert=...] + [--ca-target-distribution=] [--delete-package=...] [--set-container-derived-from=] [--set-container-tag=] @@ -71,6 +73,15 @@ --clear-cache delete repository cache for each of the used repositories before installing any package + --ca-cert= + include additional CA certificate to import immediately after + bootstrap and make available during the build process. + --ca-target-distribution= + Specify target distribution for the import of certificates + via the --ca-cert options(s) and/or the provided + from the image description. The selected distribution is used + in KIWI to map the distribution specific CA storage path and + update tool for the import process. --delete-package= delete the given package name --description= @@ -133,6 +144,10 @@ from kiwi.privileges import Privileges from kiwi.path import Path +from kiwi.exceptions import ( + KiwiCATargetDistributionError +) + log = logging.getLogger('kiwi') @@ -252,6 +267,28 @@ def process(self): self.command_args['--set-container-derived-from'] ) + if self.command_args['--ca-cert']: + ca_certs = self.command_args['--ca-cert'] + if ca_certs: + target_distribution = \ + self.command_args['--ca-target-distribution'] or \ + self.xml_state.get_certificates_target_distribution() + if not target_distribution or not Defaults.get_ca_update_map( + target_distribution + ): + raise KiwiCATargetDistributionError( + 'No or invalid CA target distribution, {} {}'.format( + 'set via --ca-target-distribution.', + 'allowed values are {}'.format( + Defaults.get_ca_target_distributions() + ) + ) + ) + for certificate in ca_certs: + self.xml_state.add_certificate( + certificate, target_distribution + ) + self.run_checks(self.checks_after_command_args) log.info('Preparing new root system') @@ -279,6 +316,9 @@ def process(self): # call post_bootstrap.sh script if present setup.call_post_bootstrap_script() + # Setup custom CA certificates after bootstrap package install + system.setup_ca_certificates() + system.install_system( manager ) diff --git a/kiwi/tasks/system_prepare.py b/kiwi/tasks/system_prepare.py index 42a0e4b5cae..b66c9711751 100644 --- a/kiwi/tasks/system_prepare.py +++ b/kiwi/tasks/system_prepare.py @@ -28,6 +28,8 @@ [--add-repo-credentials=...] [--add-package=...] [--add-bootstrap-package=...] + [--ca-cert=...] + [--ca-target-distribution=] [--delete-package=...] [--set-container-derived-from=] [--set-container-tag=] @@ -69,6 +71,15 @@ --clear-cache delete repository cache for each of the used repositories before installing any package + --ca-cert= + include additional CA certificate to import immediately after + bootstrap and make available during the build process. + --ca-target-distribution= + Specify target distribution for the import of certificates + via the --ca-cert options(s) and/or the provided + from the image description. The selected distribution is used + in KIWI to map the distribution specific CA storage path and + update tool for the import process. --delete-package= delete the given package name --description= @@ -129,6 +140,10 @@ from kiwi.defaults import Defaults from kiwi.system.profile import Profile +from kiwi.exceptions import ( + KiwiCATargetDistributionError +) + log = logging.getLogger('kiwi') @@ -235,6 +250,28 @@ def process(self): self.command_args['--set-container-derived-from'] ) + if self.command_args['--ca-cert']: + ca_certs = self.command_args['--ca-cert'] + if ca_certs: + target_distribution = \ + self.command_args['--ca-target-distribution'] or \ + self.xml_state.get_certificates_target_distribution() + if not target_distribution or not Defaults.get_ca_update_map( + target_distribution + ): + raise KiwiCATargetDistributionError( + 'No or invalid CA target distribution, {} {}'.format( + 'set via --ca-target-distribution.', + 'allowed values are {}'.format( + Defaults.get_ca_target_distributions() + ) + ) + ) + for certificate in ca_certs: + self.xml_state.add_certificate( + certificate, target_distribution + ) + self.run_checks(self.checks_after_command_args) log.info('Preparing system') @@ -262,6 +299,9 @@ def process(self): # call post_bootstrap.sh script if present setup.call_post_bootstrap_script() + # Setup custom CA certificates after bootstrap package install + system.setup_ca_certificates() + system.install_system( manager ) diff --git a/kiwi/xml_parse.py b/kiwi/xml_parse.py index 2103d6ce518..9b0ecbb6295 100644 --- a/kiwi/xml_parse.py +++ b/kiwi/xml_parse.py @@ -16,7 +16,7 @@ # kiwi/schema/kiwi_for_generateDS.xsd # # Command line: -# /home/ms/.cache/pypoetry/virtualenvs/kiwi-Btua-i95-py3.11/bin/generateDS.py -f --external-encoding="utf-8" --no-dates --no-warnings -o "kiwi/xml_parse.py" kiwi/schema/kiwi_for_generateDS.xsd +# /home/ms/.cache/pypoetry/virtualenvs/kiwi-ZjQOFkNX-py3.11/bin/generateDS.py -f --external-encoding="utf-8" --no-dates --no-warnings -o "kiwi/xml_parse.py" kiwi/schema/kiwi_for_generateDS.xsd # # Current working directory (os.getcwd()): # kiwi @@ -813,7 +813,7 @@ class image(GeneratedsSuper): """The root element of the configuration file""" subclass = None superclass = None - def __init__(self, name=None, displayname=None, id=None, schemaversion=None, noNamespaceSchemaLocation=None, schemaLocation=None, include=None, description=None, preferences=None, profiles=None, users=None, drivers=None, strip=None, repository=None, containers=None, packages=None, extension=None): + def __init__(self, name=None, displayname=None, id=None, schemaversion=None, noNamespaceSchemaLocation=None, schemaLocation=None, include=None, certificates=None, description=None, preferences=None, profiles=None, users=None, drivers=None, strip=None, repository=None, containers=None, packages=None, extension=None): self.original_tagname_ = None self.name = _cast(None, name) self.displayname = _cast(None, displayname) @@ -825,6 +825,10 @@ def __init__(self, name=None, displayname=None, id=None, schemaversion=None, noN self.include = [] else: self.include = include + if certificates is None: + self.certificates = [] + else: + self.certificates = certificates if description is None: self.description = [] else: @@ -881,6 +885,11 @@ def set_include(self, include): self.include = include def add_include(self, value): self.include.append(value) def insert_include_at(self, index, value): self.include.insert(index, value) def replace_include_at(self, index, value): self.include[index] = value + def get_certificates(self): return self.certificates + def set_certificates(self, certificates): self.certificates = certificates + def add_certificates(self, value): self.certificates.append(value) + def insert_certificates_at(self, index, value): self.certificates.insert(index, value) + def replace_certificates_at(self, index, value): self.certificates[index] = value def get_description(self): return self.description def set_description(self, description): self.description = description def add_description(self, value): self.description.append(value) @@ -953,6 +962,7 @@ def validate_safe_posix_name(self, value): def hasContent_(self): if ( self.include or + self.certificates or self.description or self.preferences or self.profiles or @@ -1014,6 +1024,8 @@ def exportChildren(self, outfile, level, namespaceprefix_='', name_='image', fro eol_ = '' for include_ in self.include: include_.export(outfile, level, namespaceprefix_, name_='include', pretty_print=pretty_print) + for certificates_ in self.certificates: + certificates_.export(outfile, level, namespaceprefix_, name_='certificates', pretty_print=pretty_print) for description_ in self.description: description_.export(outfile, level, namespaceprefix_, name_='description', pretty_print=pretty_print) for preferences_ in self.preferences: @@ -1075,6 +1087,11 @@ def buildChildren(self, child_, node, nodeName_, fromsubclass_=False): obj_.build(child_) self.include.append(obj_) obj_.original_tagname_ = 'include' + elif nodeName_ == 'certificates': + obj_ = certificates.factory() + obj_.build(child_) + self.certificates.append(obj_) + obj_.original_tagname_ = 'certificates' elif nodeName_ == 'description': obj_ = description.factory() obj_.build(child_) @@ -2707,6 +2724,174 @@ def buildChildren(self, child_, node, nodeName_, fromsubclass_=False): # end class container +class certificates(GeneratedsSuper): + subclass = None + superclass = None + def __init__(self, profiles=None, target_distribution=None, certificate=None): + self.original_tagname_ = None + self.profiles = _cast(None, profiles) + self.target_distribution = _cast(None, target_distribution) + if certificate is None: + self.certificate = [] + else: + self.certificate = certificate + def factory(*args_, **kwargs_): + if CurrentSubclassModule_ is not None: + subclass = getSubclassFromModule_( + CurrentSubclassModule_, certificates) + if subclass is not None: + return subclass(*args_, **kwargs_) + if certificates.subclass: + return certificates.subclass(*args_, **kwargs_) + else: + return certificates(*args_, **kwargs_) + factory = staticmethod(factory) + def get_certificate(self): return self.certificate + def set_certificate(self, certificate): self.certificate = certificate + def add_certificate(self, value): self.certificate.append(value) + def insert_certificate_at(self, index, value): self.certificate.insert(index, value) + def replace_certificate_at(self, index, value): self.certificate[index] = value + def get_profiles(self): return self.profiles + def set_profiles(self, profiles): self.profiles = profiles + def get_target_distribution(self): return self.target_distribution + def set_target_distribution(self, target_distribution): self.target_distribution = target_distribution + def hasContent_(self): + if ( + self.certificate + ): + return True + else: + return False + def export(self, outfile, level, namespaceprefix_='', name_='certificates', namespacedef_='', pretty_print=True): + imported_ns_def_ = GenerateDSNamespaceDefs_.get('certificates') + if imported_ns_def_ is not None: + namespacedef_ = imported_ns_def_ + if pretty_print: + eol_ = '\n' + else: + eol_ = '' + if self.original_tagname_ is not None: + name_ = self.original_tagname_ + showIndent(outfile, level, pretty_print) + outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', )) + already_processed = set() + self.exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='certificates') + if self.hasContent_(): + outfile.write('>%s' % (eol_, )) + self.exportChildren(outfile, level + 1, namespaceprefix_='', name_='certificates', pretty_print=pretty_print) + showIndent(outfile, level, pretty_print) + outfile.write('%s' % (namespaceprefix_, name_, eol_)) + else: + outfile.write('/>%s' % (eol_, )) + def exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='certificates'): + if self.profiles is not None and 'profiles' not in already_processed: + already_processed.add('profiles') + outfile.write(' profiles=%s' % (self.gds_encode(self.gds_format_string(quote_attrib(self.profiles), input_name='profiles')), )) + if self.target_distribution is not None and 'target_distribution' not in already_processed: + already_processed.add('target_distribution') + outfile.write(' target_distribution=%s' % (self.gds_encode(self.gds_format_string(quote_attrib(self.target_distribution), input_name='target_distribution')), )) + def exportChildren(self, outfile, level, namespaceprefix_='', name_='certificates', fromsubclass_=False, pretty_print=True): + if pretty_print: + eol_ = '\n' + else: + eol_ = '' + for certificate_ in self.certificate: + certificate_.export(outfile, level, namespaceprefix_, name_='certificate', pretty_print=pretty_print) + def build(self, node): + already_processed = set() + self.buildAttributes(node, node.attrib, already_processed) + for child in node: + nodeName_ = Tag_pattern_.match(child.tag).groups()[-1] + self.buildChildren(child, node, nodeName_) + return self + def buildAttributes(self, node, attrs, already_processed): + value = find_attr_value_('profiles', node) + if value is not None and 'profiles' not in already_processed: + already_processed.add('profiles') + self.profiles = value + value = find_attr_value_('target_distribution', node) + if value is not None and 'target_distribution' not in already_processed: + already_processed.add('target_distribution') + self.target_distribution = value + self.target_distribution = ' '.join(self.target_distribution.split()) + def buildChildren(self, child_, node, nodeName_, fromsubclass_=False): + if nodeName_ == 'certificate': + obj_ = certificate.factory() + obj_.build(child_) + self.certificate.append(obj_) + obj_.original_tagname_ = 'certificate' +# end class certificates + + +class certificate(GeneratedsSuper): + subclass = None + superclass = None + def __init__(self, name=None): + self.original_tagname_ = None + self.name = _cast(None, name) + def factory(*args_, **kwargs_): + if CurrentSubclassModule_ is not None: + subclass = getSubclassFromModule_( + CurrentSubclassModule_, certificate) + if subclass is not None: + return subclass(*args_, **kwargs_) + if certificate.subclass: + return certificate.subclass(*args_, **kwargs_) + else: + return certificate(*args_, **kwargs_) + factory = staticmethod(factory) + def get_name(self): return self.name + def set_name(self, name): self.name = name + def hasContent_(self): + if ( + + ): + return True + else: + return False + def export(self, outfile, level, namespaceprefix_='', name_='certificate', namespacedef_='', pretty_print=True): + imported_ns_def_ = GenerateDSNamespaceDefs_.get('certificate') + if imported_ns_def_ is not None: + namespacedef_ = imported_ns_def_ + if pretty_print: + eol_ = '\n' + else: + eol_ = '' + if self.original_tagname_ is not None: + name_ = self.original_tagname_ + showIndent(outfile, level, pretty_print) + outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', )) + already_processed = set() + self.exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='certificate') + if self.hasContent_(): + outfile.write('>%s' % (eol_, )) + self.exportChildren(outfile, level + 1, namespaceprefix_='', name_='certificate', pretty_print=pretty_print) + outfile.write('%s' % (namespaceprefix_, name_, eol_)) + else: + outfile.write('/>%s' % (eol_, )) + def exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='certificate'): + if self.name is not None and 'name' not in already_processed: + already_processed.add('name') + outfile.write(' name=%s' % (self.gds_encode(self.gds_format_string(quote_attrib(self.name), input_name='name')), )) + def exportChildren(self, outfile, level, namespaceprefix_='', name_='certificate', fromsubclass_=False, pretty_print=True): + pass + def build(self, node): + already_processed = set() + self.buildAttributes(node, node.attrib, already_processed) + for child in node: + nodeName_ = Tag_pattern_.match(child.tag).groups()[-1] + self.buildChildren(child, node, nodeName_) + return self + def buildAttributes(self, node, attrs, already_processed): + value = find_attr_value_('name', node) + if value is not None and 'name' not in already_processed: + already_processed.add('name') + self.name = value + def buildChildren(self, child_, node, nodeName_, fromsubclass_=False): + pass +# end class certificate + + class repository(k_source): """The Name of the Repository""" subclass = None @@ -10355,6 +10540,8 @@ def main(): "argument", "bootloader", "bootloadersettings", + "certificate", + "certificates", "collectionModule", "configoption", "container", diff --git a/kiwi/xml_state.py b/kiwi/xml_state.py index 8edaf7b699f..eecb1bb56b3 100644 --- a/kiwi/xml_state.py +++ b/kiwi/xml_state.py @@ -2453,6 +2453,60 @@ def add_repository( ) ) + def add_certificate(self, cert_file: str, target_distribution: str) -> None: + """ + Add to main section + The main section will be created if it does not exist. Also + setup the target_distribution in the resulting main section. + """ + certificates_section = self._profiled( + self.xml_data.get_certificates() + ) + if not certificates_section: + self.xml_data.set_certificates( + [ + xml_parse.certificates( + target_distribution=target_distribution, + certificate=[xml_parse.certificate(name=cert_file)] + ) + ] + ) + else: + certificates_section[0].set_target_distribution( + target_distribution + ) + certificates_section[0].add_certificate( + xml_parse.certificate( + name=cert_file + ) + ) + + def get_certificates(self) -> List[str]: + """ + Read list of certificates + """ + cert_list = [] + certificates_section = self._profiled( + self.xml_data.get_certificates() + ) + if certificates_section: + for certificate in certificates_section[0].get_certificate(): + cert_list.append(certificate.get_name()) + return sorted(list(set(cert_list))) + + def get_certificates_target_distribution(self) -> str: + """ + Read CA target distribution + """ + target_distribution = '' + certificates_section = self._profiled( + self.xml_data.get_certificates() + ) + if certificates_section: + target_distribution = \ + certificates_section[0].get_target_distribution() + return target_distribution + def resolve_this_path(self) -> None: """ Resolve any this:// repo source path into the path diff --git a/test/data/example_config.xml b/test/data/example_config.xml index 8f98eb4446f..a82eabfe3b3 100644 --- a/test/data/example_config.xml +++ b/test/data/example_config.xml @@ -1,6 +1,9 @@ + + + diff --git a/test/unit/cli_test.py b/test/unit/cli_test.py index d4484688953..a511d55baba 100644 --- a/test/unit/cli_test.py +++ b/test/unit/cli_test.py @@ -62,6 +62,8 @@ def setup(self): '--set-repo-credentials': None, '--add-package': [], '--add-bootstrap-package': [], + '--ca-cert': [], + '--ca-target-distribution': None, '--delete-package': [], '--set-container-derived-from': None, '--set-container-tag': None, diff --git a/test/unit/system/prepare_test.py b/test/unit/system/prepare_test.py index 3908f29f232..ebbf508e20f 100644 --- a/test/unit/system/prepare_test.py +++ b/test/unit/system/prepare_test.py @@ -530,3 +530,73 @@ def test_clean_package_manager_leftovers(self, mock_manager, mock_repo): mock_manager.return_value.__enter__.return_value = manager self.system.clean_package_manager_leftovers() manager.clean_leftovers.assert_called_once_with() + + @patch('os.path.isdir', return_value=True) + @patch('os.path.exists', return_value=True) + def test_setup_ca_certificates_name_exists_but_is_dir( + self, mock_os_path_exists, mock_os_path_isdir + ): + with self._caplog.at_level(logging.WARNING): + self.system.setup_ca_certificates() + assert '/some/ca/filename: does not exist or is directory' in \ + self._caplog.text + + def test_setup_ca_certificates_no_update_info(self): + self.state.get_certificates_target_distribution = Mock( + return_value=None + ) + with self._caplog.at_level(logging.WARNING): + self.system.setup_ca_certificates() + assert 'Could not determine CA update tool, skipping setup' in \ + self._caplog.text + + @patch('kiwi.system.prepare.Command.run') + @patch('kiwi.system.prepare.DataSync') + @patch('os.path.exists', return_value=True) + @patch('kiwi.system.prepare.Path') + @patch('os.path.isdir', return_value=False) + def test_setup_ca_certificates_success( + self, + mock_os_path_isdir, + mock_Path, + mock_os_path_exists, + mock_DataSync, + mock_Command_run + ): + self.state.get_certificates_target_distribution = Mock( + return_value='suse' + ) + mock_DataSync_instance = Mock() + mock_DataSync.return_value = mock_DataSync_instance + + self.system.setup_ca_certificates() + + ca_chroot_path = 'root_dir/etc/pki/trust/anchors' + mock_Path.create.assert_called_once_with(ca_chroot_path) + mock_DataSync.assert_called_once_with( + '/some/ca/filename', ca_chroot_path + ) + mock_Command_run.assert_called_once_with( + ['chroot', 'root_dir', 'update-ca-certificates'] + ) + + @patch('kiwi.system.prepare.Command.run') + @patch('kiwi.system.prepare.DataSync') + @patch('os.path.exists', return_value=True) + @patch('kiwi.system.prepare.Path') + @patch('os.path.isdir', return_value=False) + def test_setup_ca_certificates_raises( + self, + mock_os_path_isdir, + mock_Path, + mock_os_path_exists, + mock_DataSync, + mock_Command_run + ): + self.state.get_certificates_target_distribution = Mock( + return_value='suse' + ) + mock_DataSync.side_effect = Exception + + with raises(KiwiBootStrapPhaseFailed): + self.system.setup_ca_certificates() diff --git a/test/unit/tasks/system_build_test.py b/test/unit/tasks/system_build_test.py index 7a16e9ba670..2cb3c345678 100644 --- a/test/unit/tasks/system_build_test.py +++ b/test/unit/tasks/system_build_test.py @@ -1,12 +1,15 @@ import logging import sys import os -from pytest import fixture +from pytest import ( + fixture, raises +) from unittest.mock import ( patch, call, Mock, MagicMock ) import kiwi +from kiwi.exceptions import KiwiCATargetDistributionError from ..test_helper import argv_kiwi_tests @@ -93,6 +96,8 @@ def _init_command_args(self): self.task.command_args['--add-container-label'] = [] self.task.command_args['--clear-cache'] = False self.task.command_args['--signing-key'] = [] + self.task.command_args['--ca-cert'] = [] + self.task.command_args['--ca-target-distribution'] = None @patch('kiwi.logger.Logger.set_logfile') @patch('kiwi.xml_state.XMLState.get_repositories_signing_keys') @@ -162,6 +167,7 @@ def test_process_system_build( system_prepare.install_bootstrap.assert_called_once_with( manager.__enter__.return_value, [] ) + system_prepare.setup_ca_certificates.assert_called_once_with() system_prepare.install_system.assert_called_once_with( manager.__enter__.return_value ) @@ -445,3 +451,25 @@ def test_process_system_prepare_ignore_repos_used_for_build( self.task.command_args['--ignore-repos-used-for-build'] = True self.task.process() mock_delete_repos.assert_called_once_with() + + @patch('kiwi.xml_state.XMLState.add_certificate') + @patch('kiwi.tasks.system_build.SystemPrepare') + @patch('kiwi.logger.Logger.set_logfile') + def test_process_system_prepare_with_custom_ca_certs( + self, mock_log, mock_SystemPrepare, mock_add_certificate + ): + system_prepare = Mock() + system_prepare.setup_repositories = Mock( + return_value=MagicMock() + ) + mock_SystemPrepare.return_value.__enter__.return_value = system_prepare + self._init_command_args() + self.task.command_args['--ca-cert'] = ['/some/ca/filename'] + with raises(KiwiCATargetDistributionError): + self.task.process() + self.task.command_args['--ca-target-distribution'] = 'suse' + self.task.process() + mock_add_certificate.assert_called_once_with( + '/some/ca/filename', 'suse' + ) + system_prepare.setup_ca_certificates.assert_called_once_with() diff --git a/test/unit/tasks/system_prepare_test.py b/test/unit/tasks/system_prepare_test.py index 3c963140f80..1251edaf86a 100644 --- a/test/unit/tasks/system_prepare_test.py +++ b/test/unit/tasks/system_prepare_test.py @@ -1,12 +1,15 @@ import logging import sys import os -from pytest import fixture +from pytest import ( + fixture, raises +) from unittest.mock import ( patch, call, Mock, MagicMock ) import kiwi +from kiwi.exceptions import KiwiCATargetDistributionError from ..test_helper import argv_kiwi_tests @@ -86,6 +89,8 @@ def _init_command_args(self): self.task.command_args['--set-container-tag'] = None self.task.command_args['--add-container-label'] = [] self.task.command_args['--signing-key'] = [] + self.task.command_args['--ca-cert'] = [] + self.task.command_args['--ca-target-distribution'] = None @patch('kiwi.xml_state.XMLState.get_repositories_signing_keys') @patch('kiwi.tasks.system_prepare.SystemPrepare') @@ -410,3 +415,25 @@ def test_process_system_prepare_delete_repos_used_for_build( self.task.command_args['--ignore-repos-used-for-build'] = True self.task.process() mock_delete_repos.assert_called_once_with() + + @patch('kiwi.xml_state.XMLState.add_certificate') + @patch('kiwi.tasks.system_prepare.SystemPrepare') + @patch('kiwi.logger.Logger.set_logfile') + def test_ca_certs_path_handling( + self, mock_log, mock_SystemPrepare, mock_add_certificate + ): + system_prepare = Mock() + system_prepare.setup_repositories = Mock( + return_value=MagicMock() + ) + mock_SystemPrepare.return_value.__enter__.return_value = system_prepare + self._init_command_args() + self.task.command_args['--ca-cert'] = ['/some/ca/filename'] + with raises(KiwiCATargetDistributionError): + self.task.process() + self.task.command_args['--ca-target-distribution'] = 'suse' + self.task.process() + mock_add_certificate.assert_called_once_with( + '/some/ca/filename', 'suse' + ) + system_prepare.setup_ca_certificates.assert_called_once_with() diff --git a/test/unit/xml_state_test.py b/test/unit/xml_state_test.py index cc8a2ef9fc0..5da9fcccec4 100644 --- a/test/unit/xml_state_test.py +++ b/test/unit/xml_state_test.py @@ -1398,3 +1398,21 @@ def test_btrfs_default_volume_requested( assert self.state.btrfs_default_volume_requested() is False mock_get_btrfs_set_default_volume.return_value = None assert self.state.btrfs_default_volume_requested() is True + + def test_get_certificates(self): + assert self.state.get_certificates() == ['/some/ca/filename'] + assert self.state.get_certificates_target_distribution() == 'suse' + self.state.add_certificate('/new/file', 'rhel') + assert self.state.get_certificates() == [ + '/new/file', '/some/ca/filename' + ] + assert self.state.get_certificates_target_distribution() == 'rhel' + self.state.add_certificate('/new/file', 'rhel') + assert self.state.get_certificates() == [ + '/new/file', '/some/ca/filename' + ] + assert self.apt_state.get_certificates() == [] + assert self.apt_state.get_certificates_target_distribution() == '' + self.apt_state.add_certificate('/new/file', 'debian') + assert self.apt_state.get_certificates() == ['/new/file'] + assert self.apt_state.get_certificates_target_distribution() == 'debian'