mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
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>
199 lines
6.9 KiB
Python
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)
|