#!/usr/bin/env python3 """Install a package by its logical id from the meta/packages.yml registry. The registry owns every distro mapping and every acquisition path, so a role states what it needs and never how to get it:: - name: "📦 Install the userspace NFS server" package_install: id: nfs-ganesha - name: "📦 Install the storage stack" package_install: id: - nfs-ganesha - libntirpc Distribution repositories, the AUR, COPR, PPA and source builds all run through here, including the unprivileged build environment the AUR needs. :mod:`utils.packages.plan` decides which module calls that takes; this plugin only executes them. Args: id: logical package id, or a list of ids, each declared either in the calling role's own meta/packages.yml or in the shared root meta/packages.yml. state: ``present`` (default) or ``absent``. """ from __future__ import annotations import time from typing import Any from ansible.errors import AnsibleActionFail from ansible.plugins.action import ActionBase from utils.packages.calls import ( GENERIC_PACKAGE, MANAGER_ARGS, STATE_PRESENT, STATES, ModuleCall, ) from utils.packages.facts import distribution_and_family, read_fact from utils.packages.outcome import aggregate from utils.packages.plan import build_plan from utils.packages.registry import build_registry, project_root_from_env, resolve from utils.packages.schema import ROLE_FILE_META_PACKAGES, PackagesShapeError class ActionModule(ActionBase): def run( self, tmp: Any = None, task_vars: dict[str, Any] | None = None ) -> dict[str, Any]: super().run(tmp, task_vars) task_vars = task_vars or {} state = self._state() facts = self._target_facts(task_vars) distribution, os_family = distribution_and_family(facts) if not distribution: raise AnsibleActionFail( "package_install needs gathered facts; ansible_facts.distribution " "and ansible_facts.os_family must both be set." ) package_ids = self._package_ids() registry = self._registry() role = self._role() results: list[dict[str, Any]] = [] skipped: list[str] = [] for package_id in package_ids: spec = self._spec_for(registry, package_id, distribution, os_family, role) plan = self._plan_for(spec, state) if not plan: skipped.append(package_id) continue results.extend(self._run_plan(plan, task_vars, facts)) return aggregate(results, skipped, distribution) def _plan_for(self, spec, state) -> list[ModuleCall]: try: return build_plan(spec, state) except ValueError as exc: raise AnsibleActionFail(str(exc)) from exc def _run_plan( self, plan: list[ModuleCall], task_vars: dict[str, Any], facts: dict[str, Any], ) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] for call in plan: result = self._attempt(call, task_vars, facts) results.append(result) if result.get("failed"): break return results def _attempt( self, call: ModuleCall, task_vars: dict[str, Any], facts: dict[str, Any] ) -> dict[str, Any]: """Run *call* once, or up to its retry policy while it keeps failing. Returns the last result, tagged with the module that produced it.""" if call.retry is None: return self._tagged(call, task_vars, facts) result: dict[str, Any] = {} for attempt in range(1, call.retry.attempts + 1): result = self._tagged(call, task_vars, facts) if not result.get("failed"): return result if attempt < call.retry.attempts: time.sleep(call.retry.seconds) return result def _tagged( self, call: ModuleCall, task_vars: dict[str, Any], facts: dict[str, Any] ) -> dict[str, Any]: result = self._execute(call, task_vars, facts) result["module"] = call.module return result def _execute( self, call: ModuleCall, task_vars: dict[str, Any], facts: dict[str, Any] ) -> dict[str, Any]: args = {k: v for k, v in call.args.items() if v is not None} module = self._module_name(call, facts) if call.module == GENERIC_PACKAGE: args.update(MANAGER_ARGS.get(module, {})) if not call.become_user: return self._execute_module( module_name=module, module_args=args, task_vars=task_vars ) become = self._connection.become if become is None: raise AnsibleActionFail( f"package_install must escalate to '{call.become_user}' to build " "this package, but the task runs without become." ) previous = become.get_option("become_user") become.set_option("become_user", call.become_user) try: return self._execute_module( module_name=module, module_args=args, task_vars=task_vars ) finally: become.set_option("become_user", previous) def _module_name(self, call: ModuleCall, facts: dict[str, Any]) -> str: if call.module != GENERIC_PACKAGE: return call.module pkg_mgr = read_fact(facts, "pkg_mgr") if not pkg_mgr: raise AnsibleActionFail( "package_install needs ansible_facts.pkg_mgr to choose a package " "manager module; gather facts before installing." ) return pkg_mgr def _target_facts(self, task_vars: dict[str, Any]) -> dict[str, Any]: """Facts of the host the package modules will actually run on. Under ``delegate_to`` task_vars still carries the INVENTORY host's facts, so a fedora node delegating to a Debian controller resolves the RedHat mapping and then runs ``dnf`` on a machine that has none. The delegation target is asked directly instead. """ delegate = getattr(self._task, "delegate_to", None) if not delegate or delegate == task_vars.get("inventory_hostname"): return task_vars.get("ansible_facts") or {} result = self._execute_module( module_name="ansible.builtin.setup", module_args={"gather_subset": ["!all", "!min", "distribution", "pkg_mgr"]}, task_vars=task_vars, ) if result.get("failed"): raise AnsibleActionFail( f"package_install could not gather facts from the delegation " f"target '{delegate}': {result.get('msg', 'setup failed')}" ) return result.get("ansible_facts") or {} def _role(self) -> str | None: role = getattr(self._task, "_role", None) return str(role.get_name(include_role_fqcn=False)) if role is not None else None def _registry(self): try: return build_registry(project_root_from_env()) except PackagesShapeError as exc: raise AnsibleActionFail(str(exc)) from exc def _state(self) -> str: state = str(self._task.args.get("state", STATE_PRESENT)).strip() if state not in STATES: raise AnsibleActionFail( f"package_install state {state!r} is not one of {STATES}." ) return state def _package_ids(self) -> list[str]: raw = self._task.args.get("id") if isinstance(raw, str) and raw.strip(): return [raw.strip()] if isinstance(raw, list) and raw: return [str(item).strip() for item in raw if str(item).strip()] raise AnsibleActionFail( "package_install requires 'id' as a package id or a list of ids." ) def _spec_for( self, registry, package_id: str, distribution: str, os_family: str, role: str | None, ): declaration = registry.get(package_id) if declaration is None: raise AnsibleActionFail( f"Package id '{package_id}' is declared in no meta/packages.yml. " f"Declare it in the owning role before installing it." ) if role and not declaration.shared and declaration.role != role: raise AnsibleActionFail( f"Role '{role}' installs '{package_id}', which " f"'{declaration.role}' declares. A role may only install ids " f"from its own {ROLE_FILE_META_PACKAGES} or from the shared " f"root {ROLE_FILE_META_PACKAGES}. Move the package to the root " f"file when more than one role needs it." ) try: spec = resolve(declaration, distribution, os_family) except PackagesShapeError as exc: raise AnsibleActionFail(str(exc)) from exc if spec is None: raise AnsibleActionFail( f"Package '{package_id}' has no mapping for distribution " f"'{distribution}' (os_family '{os_family}') in {declaration.path}." ) return spec