mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Only calls the registry routes through the host's own package manager get them; a call that names a concrete module already states what it wants.
250 lines
9.1 KiB
Python
Executable File
250 lines
9.1 KiB
Python
Executable File
#!/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
|