Files
computer-playbook/utils/packages/schema.py
T
kevinveenbirkenbachandClaude Opus 5 6b5388c091 fix(nfs-server): bootstrap centos ganesha repo
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>
2026-08-06 10:47:38 +02:00

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)