mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
The inline repo definition pinned gpgkey to www.centos.org, a single non-CDN origin. In run 30937913632 it was unreachable for at least 17m25s and killed 2 of 23 centos ganesha installs; dnf's own retries=5 was already active on the host and did not help. centos-release-nfs-ganesha11 ships the same repo with metalink failover and gpgkey=file://, planting the key through Requires: centos-release-storage-common. Verified end to end against quay.io/centos/centos:latest and ghcr.io/kevinveenbirkenbach/pkgmgr-centos:stable. The BOOTSTRAP_BASEURL entry keeps the availability probe verifying both names instead of degrading them to an inconclusive warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
6.0 KiB
Python
173 lines
6.0 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.
|
|
"""
|
|
|
|
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."""
|
|
|
|
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)
|