Files
computer-playbook/plugins/action/package_install.py
T
kevinveenbirkenbachandClaude Opus 5 9f1ef6ecb5 fix(package-install): report what a crashed module said, and retry external fetches
A module that dies on an unhandled exception carries no msg: the AnsiballZ wrapper returns only failed and exception, whose event holds the sole account of what went wrong. The aggregation defaulted to the literal string package install failed and discarded it, so an AUR fetch that threw in run 30713666732 reported nothing at all and took a full investigation to place. Walk msg, exception.event.msg, exception, module_stderr, stderr instead, and tag every result with the module that produced it. This applies to apt, dnf, pacman and every other module the plugin drives, not only to the AUR.

The aur module fetches the RPC record and the snapshot tarball through bare open_url calls with no error handling, so one blip kills a deploy; distribution repositories need none of this because apt, dnf and pacman retry inside their own transport. Give the calls that reach an endpoint of their own a single shared policy. Only the AUR path is declared today; copr, ppa and build carry it against the day they are.

Split the result along its responsibilities so no file passes 250 lines: calls.py holds the vocabulary, sources.py one builder per acquisition path, plan.py only the dispatcher, outcome.py the reporting and facts.py the fact shapes. The action plugin is left with executing modules. Tests follow the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:07:36 +02:00

247 lines
9.0 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,
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 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