diff --git a/id_configurator/modules/certificates.py b/id_configurator/modules/certificates.py index 8a31f3f..8ecf64a 100644 --- a/id_configurator/modules/certificates.py +++ b/id_configurator/modules/certificates.py @@ -213,8 +213,18 @@ class CertificatesModule(Module): logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries)) if etccertsdir_relative == _ETCCERTSDIR: - cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) - cert_file.parent.mkdir(parents=True, exist_ok=True) + # Not context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) directly: on a + # host that already has this symlink (e.g. re-applying to a live + # `--dst /`), resolving the leaf itself would follow it straight + # to the bundle we just wrote, and the unlink+symlink_to below + # would then target the bundle's own path instead of this one - + # replacing the bundle with a symlink to itself. Resolving only + # the containing directory keeps the leaf name literal. + cert_file_dir = context.dst_path( + posixpath.dirname(_OPENSSL_DEFAULT_CERT_FILE) + ) + cert_file_dir.mkdir(parents=True, exist_ok=True) + cert_file = cert_file_dir / posixpath.basename(_OPENSSL_DEFAULT_CERT_FILE) cert_file.unlink(missing_ok=True) cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}") diff --git a/library/id_configurator.py b/library/id_configurator.py new file mode 100644 index 0000000..93dc657 --- /dev/null +++ b/library/id_configurator.py @@ -0,0 +1,635 @@ +#!/usr/bin/python +"""Ansible module wrapping id-configurator. + +Applies an id-configurator configuration directly to this host's live root +filesystem, exactly like the `id_configurator` role does, but as a native +Ansible module: it imports id-configurator's own Python API in-process +(instead of shelling out to the CLI through a temporary file), which also +lets it honor Ansible's check mode - reporting which modules would be +applied without writing anything. + +This module is a thin, additive wrapper: it does not change anything about +id-configurator's own behavior (the `id_configurator` Python package, +including its module registry and every individual module, is used exactly +as-is - the same `Configuration`, `Context` and module registry the CLI +itself uses). +""" + +from __future__ import annotations + +from pathlib import Path + +from ansible.module_utils.basic import AnsibleModule + +DOCUMENTATION = r""" +module: id_configurator +short_description: Apply an id-configurator configuration to this host. +description: + - Assembles the given options into an id-configurator configuration and + applies it directly to this host's live root filesystem, by importing + id-configurator's own Python API in-process (no CLI subprocess, no + temporary file). + - The option names and structure mirror id_configurator.config.Configuration + and id_configurator.config.ConfigurationsBlock one for one. The + `id_configurator` Python package must already be installed on the + target host, using the same Python interpreter Ansible uses there. + - Supports check mode - reports which modules would be applied without + writing anything. +author: + - InterDiode +options: + uuid: + type: str + required: true + description: Configuration.uuid - a UUID identifying this configuration. + organisation: + type: str + required: true + description: Configuration.organisation. + description: + type: str + default: "" + description: Configuration.description. + certificates: + type: list + elements: dict + default: [] + description: ConfigurationsBlock.certificates - root certificates to install. + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded certificate content. + apt: + type: dict + default: {} + description: ConfigurationsBlock.apt. + suboptions: + sources: + type: list + elements: dict + default: [] + description: DEB822 APT sources (AptSource). + suboptions: + name: + type: str + required: true + description: A short, unique name for this source. + types: + type: list + elements: str + default: [deb] + description: DEB822 "Types" field. + uris: + type: list + elements: str + required: true + description: DEB822 "URIs" field. + suites: + type: list + elements: str + required: true + description: DEB822 "Suites" field. + components: + type: list + elements: str + default: [] + description: DEB822 "Components" field. + architectures: + type: list + elements: str + default: [] + description: DEB822 "Architectures" field. + languages: + type: list + elements: str + default: [] + description: DEB822 "Languages" field. + targets: + type: list + elements: str + default: [] + description: DEB822 "Targets" field. + enabled: + type: bool + default: true + description: DEB822 "Enabled" field. + trusted: + type: bool + description: DEB822 "Trusted" field. + signed_by: + type: str + description: DEB822 "Signed-By" field. + pdiffs: + type: bool + description: DEB822 "PDiffs" field. + by_hash: + type: bool + description: DEB822 "By-Hash" field. + allow_insecure: + type: bool + description: DEB822 "Allow-Insecure" field. + allow_weak: + type: bool + description: DEB822 "Allow-Weak" field. + allow_downgrade_to_insecure: + type: bool + description: DEB822 "Allow-Downgrade-To-Insecure" field. + check_valid_until: + type: bool + description: DEB822 "Check-Valid-Until" field. + valid_until_min: + type: str + description: DEB822 "Valid-Until-Min" field. + valid_until_max: + type: str + description: DEB822 "Valid-Until-Max" field. + check_date: + type: bool + description: DEB822 "Check-Date" field. + date_max_future: + type: str + description: DEB822 "Date-Max-Future" field. + description: + type: str + description: DEB822 "Description" field. + keys: + type: list + elements: dict + default: [] + description: GPG keys trusted for the sources above (NamedContent). + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded/ASCII-armored key content. + apk: + type: dict + default: {} + description: ConfigurationsBlock.apk. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Alpine APK repositories (ApkRepository). + suboptions: + url: + type: str + required: true + description: Repository URL, e.g. ending in /main or /community. + key: + type: str + description: The signing key's name, if any (see NamedContent). + yum: + type: dict + default: {} + description: ConfigurationsBlock.yum. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: YUM/DNF repositories (YumRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + index_url: + type: str + required: true + description: The repository's baseurl. + gpg_url: + type: str + description: The repository's gpgkey URL. + keys: + type: list + elements: dict + default: [] + description: GPG keys imported for the repositories above (NamedContent). + suboptions: + name: + type: str + required: true + description: A short, unique name for this entry. + content: + type: str + required: true + description: PEM-encoded/ASCII-armored key content. + python: + type: dict + default: {} + description: ConfigurationsBlock.python. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Python package indexes (PythonRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this index. + index_url: + type: str + required: true + description: The package index's URL. + java: + type: dict + default: {} + description: ConfigurationsBlock.java. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Maven-compatible repositories (JavaRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + php: + type: dict + default: {} + description: ConfigurationsBlock.php. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Composer repositories (PhpRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + npm: + type: dict + default: {} + description: ConfigurationsBlock.npm. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: npm registries (NpmRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + rust: + type: dict + default: {} + description: ConfigurationsBlock.rust. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Cargo registries (RustRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + go: + type: dict + default: {} + description: ConfigurationsBlock.go. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: Go module proxies (GoRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + ruby: + type: dict + default: {} + description: ConfigurationsBlock.ruby. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: RubyGems sources (RubyRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. + nuget: + type: dict + default: {} + description: ConfigurationsBlock.nuget. + suboptions: + repositories: + type: list + elements: dict + default: [] + description: NuGet package sources (NuGetRepository). + suboptions: + name: + type: str + required: true + description: A short, unique name for this repository. + url: + type: str + required: true + description: The repository's URL. +""" + +EXAMPLES = r""" +- name: Apply an id-configurator configuration to this host + id_configurator: + uuid: 12345678-1234-5678-1234-567812345678 + organisation: InterDiode + certificates: + - name: Company Root CA + content: "{{ lookup('file', 'files/company-root-ca.pem') }}" + apt: + sources: + - name: internal + uris: ["https://apt.internal.example/"] + suites: ["stable"] +""" + +RETURN = r""" +applied_modules: + description: Names of the id-configurator modules that were (or, in check + mode, would be) applied, in registration order. + returned: always + type: list + elements: str + sample: [certificates, apt] +""" + +# Every key here must match one of ConfigurationsBlock's own field names +# (id_configurator/config.py) - see the module docstring above. +_CONFIGURATIONS_BLOCK_FIELDS = ( + "certificates", + "apt", + "apk", + "yum", + "python", + "java", + "php", + "npm", + "rust", + "go", + "ruby", + "nuget", +) + +_NAMED_CONTENT_OPTIONS = { + "name": {"type": "str", "required": True}, + "content": {"type": "str", "required": True}, +} + +_NAME_URL_OPTIONS = { + "name": {"type": "str", "required": True}, + "url": {"type": "str", "required": True}, +} + + +def _name_url_repository_config() -> dict: + """Argument spec for a `{repositories: [{name, url}]}` config block, + shared by java/php/npm/rust/go/ruby/nuget.""" + return { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAME_URL_OPTIONS), + }, + }, + } + + +ARGUMENT_SPEC = { + "uuid": {"type": "str", "required": True}, + "organisation": {"type": "str", "required": True}, + "description": {"type": "str", "default": ""}, + "certificates": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + "apt": { + "type": "dict", + "default": {}, + "options": { + "sources": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "types": {"type": "list", "elements": "str", "default": ["deb"]}, + "uris": {"type": "list", "elements": "str", "required": True}, + "suites": {"type": "list", "elements": "str", "required": True}, + "components": {"type": "list", "elements": "str", "default": []}, + "architectures": { + "type": "list", + "elements": "str", + "default": [], + }, + "languages": {"type": "list", "elements": "str", "default": []}, + "targets": {"type": "list", "elements": "str", "default": []}, + "enabled": {"type": "bool", "default": True}, + "trusted": {"type": "bool"}, + "signed_by": {"type": "str"}, + "pdiffs": {"type": "bool"}, + "by_hash": {"type": "bool"}, + "allow_insecure": {"type": "bool"}, + "allow_weak": {"type": "bool"}, + "allow_downgrade_to_insecure": {"type": "bool"}, + "check_valid_until": {"type": "bool"}, + "valid_until_min": {"type": "str"}, + "valid_until_max": {"type": "str"}, + "check_date": {"type": "bool"}, + "date_max_future": {"type": "str"}, + "description": {"type": "str"}, + }, + }, + "keys": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + }, + }, + "apk": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "url": {"type": "str", "required": True}, + "key": {"type": "str"}, + }, + }, + }, + }, + "yum": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "index_url": {"type": "str", "required": True}, + "gpg_url": {"type": "str"}, + }, + }, + "keys": { + "type": "list", + "elements": "dict", + "default": [], + "options": dict(_NAMED_CONTENT_OPTIONS), + }, + }, + }, + "python": { + "type": "dict", + "default": {}, + "options": { + "repositories": { + "type": "list", + "elements": "dict", + "default": [], + "options": { + "name": {"type": "str", "required": True}, + "index_url": {"type": "str", "required": True}, + }, + }, + }, + }, + "java": _name_url_repository_config(), + "php": _name_url_repository_config(), + "npm": _name_url_repository_config(), + "rust": _name_url_repository_config(), + "go": _name_url_repository_config(), + "ruby": _name_url_repository_config(), + "nuget": _name_url_repository_config(), +} + + +try: + import id_configurator.modules # noqa: F401 (registers the built-in modules) + from id_configurator.config import Configuration + from id_configurator.context import Context + from id_configurator.modules.base import all_modules + + HAS_ID_CONFIGURATOR = True + ID_CONFIGURATOR_IMPORT_ERROR = None +except ImportError as exc: + HAS_ID_CONFIGURATOR = False + ID_CONFIGURATOR_IMPORT_ERROR = exc + + +def _build_configuration_data(params: dict) -> dict: + return { + "uuid": params["uuid"], + "organisation": params["organisation"], + "description": params["description"], + "configurations": { + field: params[field] for field in _CONFIGURATIONS_BLOCK_FIELDS + }, + } + + +def _apply_configuration(configuration, check_mode: bool) -> list[str]: + """Apply `configuration` to this host's live root filesystem, returning + the names of every module that was (or, in check mode, would be) + applied. Uses id-configurator's own module registry and `Context` + unchanged - this is a thin caller, not a reimplementation.""" + root = Path("/") + context = Context(config=configuration, dst=root, src=root) + applied_modules = [] + for module_cls in all_modules(): + config_module = module_cls() + if config_module.is_relevant(context): + applied_modules.append(config_module.name) + if not check_mode: + config_module.apply(context) + return applied_modules + + +def main() -> None: + module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True) + + if not HAS_ID_CONFIGURATOR: + module.fail_json( + msg=( + "id_configurator is not importable with this host's Python " + f"interpreter: {ID_CONFIGURATOR_IMPORT_ERROR}" + ) + ) + return + + try: + configuration = Configuration.model_validate( + _build_configuration_data(module.params) + ) + except Exception as exc: # pydantic.ValidationError, kept generic on purpose + module.fail_json(msg=f"invalid id-configurator configuration: {exc}") + return + + applied_modules = _apply_configuration(configuration, module.check_mode) + module.exit_json(changed=bool(applied_modules), applied_modules=applied_modules) + + +if __name__ == "__main__": + main() diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 0708f64..3810685 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -237,3 +237,37 @@ def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> No # /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default # CAfile on this (RHEL-style) system, so it must not be created. assert not (dst / "usr/lib/ssl/cert.pem").exists() + + +def test_apply_is_idempotent_when_src_and_dst_are_the_same_live_root( + tmp_path, +) -> None: + # Regression test: applying directly to a live root (--src / --dst /, + # as the Ansible role does) where /usr/lib/ssl/cert.pem already exists + # (as it would on any host with ca-certificates already installed) must + # not resolve that pre-existing symlink away when recreating it - doing + # so would unlink/replace the bundle it points to instead, corrupting it + # into a symlink to itself and breaking every subsequent run. + root = tmp_path / "root" + etc_ssl_certs = root / "etc/ssl/certs" + etc_ssl_certs.mkdir(parents=True) + (etc_ssl_certs / "ca-certificates.crt").write_bytes(b"preexisting-bundle-bytes") + usr_lib_ssl = root / "usr/lib/ssl" + usr_lib_ssl.mkdir(parents=True) + (usr_lib_ssl / "cert.pem").symlink_to("/etc/ssl/certs/ca-certificates.crt") + + context = Context(config=make_config(True), dst=root, src=root) + + for _ in range(2): + CertificatesModule().apply(context) + + bundle = etc_ssl_certs / "ca-certificates.crt" + assert not bundle.is_symlink() + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + openssl_default = usr_lib_ssl / "cert.pem" + assert openssl_default.is_symlink() + assert ( + openssl_default.readlink().as_posix() + == "/etc/ssl/certs/ca-certificates.crt" + ) diff --git a/uv.lock b/uv.lock index a06fc60..da9f89c 100644 --- a/uv.lock +++ b/uv.lock @@ -411,7 +411,7 @@ dependencies = [ ] name = "id-configurator" source = {editable = "."} -version = "0.1.0" +version = "0.2.0" [package.dev-dependencies] dev = [