mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-13 06:06:38 +00:00
A vendor that ships a package file but runs no repository left the registry nothing to resolve, so cursor declared an empty list on Debian and RedHat and the role deployed while installing nothing. The new 'download' source names the artefact directly and pins it by digest: the URL alone would install whatever the vendor serves today. get_url verifies before and after the transfer, the artefact is cached root-owned so no other account can swap it in the window before the install, and a closing package call fails loudly when the artefact registered under a name other than the one declared. Cursor's rpm carries no signature, which is why dnf runs with disable_gpg_check; the digest is the integrity control. All 92 of its requirements resolve against Fedora 44 and CentOS Stream 10 repodata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
9.9 KiB
Python
282 lines
9.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.
|
|
|
|
``source: flatpak`` takes application ids rather than package names and
|
|
pulls them from a Flatpak remote::
|
|
|
|
blender:
|
|
centos:
|
|
source: flatpak
|
|
names: [org.blender.Blender]
|
|
flatpak:
|
|
command: blender
|
|
|
|
``flatpak.command`` is the name the role invokes. Flatpak exports a launcher
|
|
named after the application id, not after the binary inside it, so without
|
|
this key a role calling ``blender`` finds nothing on PATH.
|
|
|
|
``source: download`` fetches one vendor artefact and installs it locally,
|
|
for a vendor that publishes a ``.deb`` or ``.rpm`` but runs no repository::
|
|
|
|
cursor:
|
|
Debian:
|
|
source: download
|
|
names: [cursor]
|
|
download:
|
|
url: https://downloads.cursor.com/.../cursor_3.27.6_amd64.deb
|
|
checksum: sha256:cf5f44...
|
|
|
|
``names`` is what the artefact installs as, so removal still goes through
|
|
the package manager. ``checksum`` is mandatory: the URL alone would install
|
|
whatever the vendor serves today, and a mismatch must abort the fetch
|
|
rather than reach the package manager.
|
|
|
|
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"
|
|
SOURCE_DOWNLOAD = "download"
|
|
SOURCE_FLATPAK = "flatpak"
|
|
SOURCES: tuple[str, ...] = (
|
|
SOURCE_REPO,
|
|
SOURCE_AUR,
|
|
SOURCE_COPR,
|
|
SOURCE_PPA,
|
|
SOURCE_BUILD,
|
|
SOURCE_DOWNLOAD,
|
|
SOURCE_FLATPAK,
|
|
)
|
|
"""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; ``download`` installs a checksum-
|
|
pinned vendor artefact where the vendor publishes a package file but no
|
|
repository; ``flatpak`` installs an application id from a Flatpak remote,
|
|
which is the only upstream channel for much of the desktop set on EL."""
|
|
|
|
DOWNLOAD_CHECKSUM_PREFIXES: tuple[str, ...] = ("sha256:", "sha512:")
|
|
"""Digest algorithms strong enough to pin a vendor artefact. ``get_url``
|
|
also accepts md5 and sha1, which a prefix check must keep out."""
|
|
|
|
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
|
|
flatpak: dict[str, Any] | None = None
|
|
download: 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 _validate_download(
|
|
package_id: str, key: str, source: str, download: Any, path: Path
|
|
) -> None:
|
|
if download is None:
|
|
if source == SOURCE_DOWNLOAD:
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key} declares source "
|
|
f"'{SOURCE_DOWNLOAD}' but no download block."
|
|
)
|
|
return
|
|
if source != SOURCE_DOWNLOAD:
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key} declares a download block without "
|
|
f"source '{SOURCE_DOWNLOAD}'."
|
|
)
|
|
if not isinstance(download, dict):
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key}.download must be a mapping, "
|
|
f"got {type(download).__name__}."
|
|
)
|
|
url = download.get("url")
|
|
if not isinstance(url, str) or not url.startswith("https://"):
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key}.download.url must be an https URL."
|
|
)
|
|
checksum = download.get("checksum")
|
|
if not isinstance(checksum, str) or not checksum.startswith(
|
|
DOWNLOAD_CHECKSUM_PREFIXES
|
|
):
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key}.download.checksum must be one of "
|
|
f"{sorted(DOWNLOAD_CHECKSUM_PREFIXES)} followed by the digest; "
|
|
"without it the URL installs whatever the vendor serves today."
|
|
)
|
|
|
|
|
|
def _validate_flatpak(
|
|
package_id: str, key: str, source: str, flatpak: Any, path: Path
|
|
) -> None:
|
|
if flatpak is None:
|
|
return
|
|
if source != SOURCE_FLATPAK:
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key} declares a flatpak block without "
|
|
f"source '{SOURCE_FLATPAK}'."
|
|
)
|
|
if not isinstance(flatpak, dict):
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key}.flatpak must be a mapping, "
|
|
f"got {type(flatpak).__name__}."
|
|
)
|
|
command = flatpak.get("command")
|
|
if command is not None and not isinstance(command, str):
|
|
raise PackagesShapeError(
|
|
f"{path}: {package_id}.{key}.flatpak.command must be a string."
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
download = value.get("download")
|
|
_validate_download(package_id, key, source, download, path)
|
|
|
|
flatpak = value.get("flatpak")
|
|
_validate_flatpak(package_id, key, source, flatpak, path)
|
|
|
|
return PackageSpec(
|
|
tuple(names), str(source), repo, virtual, build, flatpak, download
|
|
)
|