Files
computer-playbook/utils/packages/schema.py
T
kevinveenbirkenbachandClaude Opus 5.5 18ea75049e perf(packages): install every role's packages per host in one run at deploy start
A constructor task after the update role and the swarm auto-placement installs,
in one package-manager call, every package id of every role the host's groups
may run: meta dependencies, service edges and every role invocation readable
from tasks and handlers, conditions ignored. Ids that must stay with their role
(a conditional install, a repository added first, a need before update) carry
batch: false in meta/packages.yml and keep their task; roles reference shared
root ids with shared: true. 42 per-role install tasks go, and a lint keeps the
split: no batched id in a role task, none in a role run ahead of the batch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 22:54:36 +01:00

199 lines
6.9 KiB
Python

"""Declaration shape for ``meta/packages.yml``.
Owns what a package entry may say and how one distro entry becomes a
concrete :class:`PackageSpec`. Finding and indexing the declarations is
:mod:`utils.packages.registry`; turning a spec into module calls is
:mod:`utils.packages.plan`.
The same shape serves the repository-root file, which holds the shared
collection any role and any inventory bundle may install from, and a
role's own file, which holds what only that role installs.
The file is a mapping of logical package id to a distro mapping. Keys are
``ansible_facts['os_family']`` values; a lowercase distribution name
(``ubuntu``, ``centos``, ``fedora``, ``arch``, ``debian``) overrides its
family when that one distribution differs::
nfs-ganesha:
Debian: [nfs-ganesha, nfs-ganesha-vfs]
RedHat: [nfs-ganesha, nfs-ganesha-vfs]
centos:
names: [nfs-ganesha, nfs-ganesha-vfs]
repo:
bootstrap_package: centos-release-nfs-ganesha11
Archlinux:
source: aur
names: [nfs-ganesha]
``repo`` carries the repository the names come from, defined inline on the
entry that needs it: a full definition (``name``/``description``/
``baseurl``/``gpgkey``) rendered as a yum repo, ``bootstrap_package`` for a
repo shipped as a package such as ``epel-release``, ``enable_existing`` to
switch on a repo the distro already ships, ``pacman_section`` to uncomment
a section of ``pacman.conf`` such as ``multilib``, ``copr``/``ppa`` for a
community repository, or ``managed_externally`` when the role sets it up
itself.
A bare list is shorthand for ``{names: [...], source: repo}``. An empty
list states that the package is deliberately not installed on that family,
which counts as covered; a *missing* key is a coverage gap.
``virtual: true`` records that the package manager satisfies the name
through an RPM ``Provides`` rather than a package of that name, so an index
lookup by name legitimately finds nothing while the install works.
Every id is installed by the one ``package_install`` run the constructor
starts each deploy with, for every role that deploy may run. ``batch: false``
on the id, next to its distro keys, takes it out of that run; the role's own
``package_install`` task then installs it, which is required when the install
is conditional or must wait for a setup step::
docker-cli:
Archlinux: [docker]
Debian:
names: [docker-ce]
repo:
managed_externally: the role adds the Docker repository first
batch: false
A role that needs an id of the shared root file references it in its own
file, so the batch knows to install it with the role::
git:
shared: true
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from utils.distros import distro_family
if TYPE_CHECKING:
from pathlib import Path
ROLE_FILE_META_PACKAGES = "meta/packages.yml"
INVENTORY_PACKAGES_VAR = "PACKAGES"
"""Inventory variable listing extra package ids a bundle installs."""
BATCH_KEY = "batch"
"""Id-level flag; ``false`` keeps the id out of the deploy-start batch."""
SHARED_REFERENCE: dict[str, bool] = {"shared": True}
"""The whole mapping of a role-file entry that references a root id."""
SOURCE_REPO = "repo"
SOURCE_AUR = "aur"
SOURCE_COPR = "copr"
SOURCE_PPA = "ppa"
SOURCE_BUILD = "build"
SOURCES: tuple[str, ...] = (
SOURCE_REPO,
SOURCE_AUR,
SOURCE_COPR,
SOURCE_PPA,
SOURCE_BUILD,
)
"""How the names are acquired. ``repo`` is the distribution's own package
manager; ``aur``, ``copr`` and ``ppa`` are the community managers Arch,
Fedora and Ubuntu ship alongside it; ``build`` compiles from source where
no manager carries the package at all."""
REPO_KEYS: frozenset[str] = frozenset(
{
"baseurl",
"bootstrap_package",
"enable_existing",
"managed_externally",
"pacman_section",
"copr",
"ppa",
}
)
"""A repository definition must declare at least one of these; the rest
(``name``, ``description``, ``gpgkey``, ``gpgcheck``, ``state``) qualify it."""
DISTRO_FAMILY: dict[str, str] = distro_family()
"""The default distro matrix mapped onto the ``os_family`` ansible reports
for it, read from the ``meta/distros.yml`` SPOT. Coverage is required for
every key."""
class PackagesShapeError(ValueError):
"""Raised when a ``meta/packages.yml`` violates the declared shape."""
@dataclass(frozen=True)
class PackageSpec:
"""Resolved install instruction for one package on one distribution."""
names: tuple[str, ...]
source: str = SOURCE_REPO
repo: dict[str, Any] | None = None
virtual: bool = False
build: dict[str, Any] | None = None
def _validate_repo(package_id: str, key: str, repo: Any, path: Path) -> None:
if not isinstance(repo, dict):
raise PackagesShapeError(
f"{path}: {package_id}.{key}.repo must be an inline repository "
f"definition (mapping), got {type(repo).__name__}."
)
if not repo.keys() & REPO_KEYS:
raise PackagesShapeError(
f"{path}: {package_id}.{key}.repo declares none of {sorted(REPO_KEYS)}."
)
def _validate_build(
package_id: str, key: str, source: str, build: Any, path: Path
) -> None:
if source == SOURCE_BUILD and not isinstance(build, dict):
raise PackagesShapeError(
f"{path}: {package_id}.{key} declares source 'build' but no build block."
)
if isinstance(build, dict) and not build.get("command"):
raise PackagesShapeError(
f"{path}: {package_id}.{key}.build must declare a 'command'."
)
def normalize_entry(package_id: str, key: str, value: Any, path: Path) -> PackageSpec:
"""Turn a bare list or a spec mapping into a :class:`PackageSpec`."""
if isinstance(value, list):
value = {"names": value}
if not isinstance(value, dict):
raise PackagesShapeError(
f"{path}: {package_id}.{key} must be a list of names or a mapping, "
f"got {type(value).__name__}."
)
names = value.get("names")
if not isinstance(names, list) or any(not isinstance(n, str) for n in names):
raise PackagesShapeError(
f"{path}: {package_id}.{key}.names must be a list of strings."
)
source = value.get("source", SOURCE_REPO)
if source not in SOURCES:
raise PackagesShapeError(
f"{path}: {package_id}.{key}.source {source!r} is not one of {SOURCES}."
)
repo = value.get("repo")
if repo is not None:
_validate_repo(package_id, key, repo, path)
virtual = value.get("virtual", False)
if not isinstance(virtual, bool):
raise PackagesShapeError(
f"{path}: {package_id}.{key}.virtual must be a boolean when set."
)
build = value.get("build")
_validate_build(package_id, key, source, build, path)
return PackageSpec(tuple(names), str(source), repo, virtual, build)