Files
computer-playbook/utils/packages/schema.py
T
kevinveenbirkenbachandClaude Opus 5.5 c9e7f950d9 Merge branch 'feature/updater' into feature/store-i18n
68 conflicts, resolved per hunk; resolutions agreed with the Updater
session were applied as agreed:

- svc-db-postgres (superuser password script and core) and
  sys-svc-rdbms realign_dedicated_db: updater's argv/stdin redesign with
  expand_argument_vars: false, nested into store-i18n's block structure.
- svc-db-redis 01_init: store-i18n's ACL password on stdin.
- sys-svc-proxy forwarded.conf.j2: store-i18n's handover-aware file,
  without the forwarded_base include (Host and X-Forwarded-Proto would
  be sent twice); forwarded_base serves the inline consumers, e.g.
  web-app-seaweedfs nginx.conf.j2.
- basic.conf.j2 and web-app-openproject proxy.conf.j2: issuer redirect,
  then handover, else updater's location/sso_acl.conf.j2; the
  server-level webserver_extra_configuration hook stays.
- web-app-keycloak: updater's admin-REST settings step and renumbered
  update steps; update/01_client.yml removed; store-i18n's design step
  and LDAP block kept; a73435cc9d arrived through rename detection.
- web-app-nextcloud OpenProject provisioning: updater's rewrite, all
  credentials on the Ruby runner's stdin.
- web-app-semaphore: store-i18n's canonical web root (a1d7835863).
- Role READMEs: no generated Cosmos/Architecture/Quick Setup/Credits
  sections; desk-* paths resolved to the dsk-* renames.

Integration fixes for what merged cleanly but did not fit together:

- web-app-matrix ansible vars: drop updater's second
  matrix_client_element_configuration_extension_json, which silently
  overrode store-i18n's design-aware value.
- url.origin instead of a stripped url.base in 19 places store-i18n
  added since 44aaa68924, same lookup targets (the double-slash lint).
- Package batch (18ea75049e, cb4d340de9): installs the deploy-start
  batch covers are removed (caffeine, cursor, blender, npm in dsk-gnt-*,
  with shared references), the rest carry batch: false and a nocheck
  with the verified reason (PyPI fallback, before the batch, vendor
  repository, configured flavor only, addon gate); dev-npm references
  the root npm id.
- sys-token-store write_many.yml: store-i18n's change-gated block and
  IS_SWARM_MODE.
- web-app-openproject: IS_COMPOSE_MODE in 02_migrate/run.yml; the
  seeded-admin lock (ccbf8f168d) ported into 04_admin/provision.yml and
  covered by the admin-state digest.
- Bucket policy (3b4ac40df5 and follow-ups) moved to web-svc-seaweedfs,
  where store-i18n's object store lives; the S3 admin secret travels on
  stdin instead of a task-level environment.
- act workflow.sh: no Exception-labelled comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:28:16 +01:00

308 lines
11 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.
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"
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
)