Files

654 lines
20 KiB
Python

#!/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:
version:
type: str
default: "1.0"
description: Configuration.version - the configuration format's own schema version.
uuid:
type: str
description: Configuration.uuid - a UUID identifying this configuration, optional.
organisation:
type: str
description: Configuration.organisation, optional.
description:
type: str
description: Configuration.description, optional.
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:
- ASCII-armored content of a GPG public key to trust this
source with. Folded inline as a multi-line Signed-By field
(no separate keyring file or gpg binary needed). Takes
precedence over signed_by_file if both are set.
signed_by_file:
type: str
description:
- Path to a keyring already expected to exist on the target
system, used verbatim as the rendered Signed-By field.
Ignored if signed_by is also set.
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.
uri_to_replace:
type: str
description:
- fnmatch pattern checked against the URIs already present in
the machine's existing apt configuration. If set, this
source is only applied when the pattern matches at least
one of them; otherwise it is skipped entirely.
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).
url_to_replace:
type: str
description:
- fnmatch pattern checked against the URLs already active in
the machine's existing /etc/apk/repositories. If set, this
repository is only applied when the pattern matches at
least one of them; otherwise it is skipped entirely.
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.
index_url_to_replace:
type: str
description:
- fnmatch pattern checked against the baseurl of every
section of every existing .repo file. If set, this
repository is only applied when the pattern matches at
least one of them; otherwise it is skipped entirely.
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 = {
"version": {"type": "str", "default": "1.0"},
"uuid": {"type": "str"},
"organisation": {"type": "str"},
"description": {"type": "str"},
"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"},
"signed_by_file": {"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"},
"uri_to_replace": {"type": "str"},
},
},
},
},
"apk": {
"type": "dict",
"default": {},
"options": {
"repositories": {
"type": "list",
"elements": "dict",
"default": [],
"options": {
"url": {"type": "str", "required": True},
"key": {"type": "str"},
"url_to_replace": {"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"},
"index_url_to_replace": {"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 {
"version": params["version"],
"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()