Files
computer-playbook/utils/packages/plan.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

116 lines
3.6 KiB
Python

"""Install plans for the package registry.
Turns a resolved :class:`~utils.packages.registry.PackageSpec` into the
ordered module calls that acquire it. Keeping this pure means every
acquisition path - distribution repository, AUR, COPR, PPA, source build,
pinned vendor download, Flatpak remote - is unit-testable without an Ansible connection; the action
plugin only executes what it is handed.
"""
from __future__ import annotations
from dataclasses import replace
from utils.packages.calls import (
GENERIC_PACKAGE,
STATE_ABSENT,
STATE_PRESENT,
ModuleCall,
package_call,
)
from utils.packages.schema import (
SOURCE_AUR,
SOURCE_BUILD,
SOURCE_COPR,
SOURCE_DOWNLOAD,
SOURCE_FLATPAK,
SOURCE_PPA,
PackageSpec,
)
from utils.packages.sources import (
aur_calls,
build_calls,
download_calls,
flatpak_calls,
repository_calls,
)
def build_plan(spec: PackageSpec, state: str) -> list[ModuleCall]:
"""Return the ordered module calls that bring ``spec`` into ``state``."""
names = list(spec.names)
if not names:
return []
if state == STATE_ABSENT:
return [package_call(names, STATE_ABSENT)]
if spec.source == SOURCE_AUR:
return aur_calls(names)
if spec.source == SOURCE_BUILD:
if not spec.build:
raise ValueError("source 'build' requires a 'build' block")
return build_calls(spec.build)
if spec.source == SOURCE_DOWNLOAD:
if not spec.download:
raise ValueError("source 'download' requires a 'download' block")
return download_calls(names, spec.download)
if spec.source == SOURCE_FLATPAK:
return flatpak_calls(names, spec.flatpak)
if spec.source in (SOURCE_COPR, SOURCE_PPA) and not spec.repo:
raise ValueError(f"source '{spec.source}' requires a 'repo' block")
if spec.repo:
return repository_calls(spec.repo, names)
return [package_call(names, STATE_PRESENT)]
def _plain(plan: list[ModuleCall]) -> bool:
if not plan or any(
call.module != GENERIC_PACKAGE
or call.become_user is not None
or set(call.args) != {"name", "state"}
for call in plan
):
return False
return len({call.args["state"] for call in plan}) == 1
def merge_plans(plans: list[list[ModuleCall]]) -> list[list[ModuleCall]]:
"""Fold the plans that only call the host's package manager into one plan.
The plain plans are aligned on their last call, so the n-th call from
the end of every plan lands in the same merged call and each plan's own
order survives: a bootstrap package still installs before the names it
unlocks. Every other plan follows unchanged, in request order.
Args:
plans: one plan per package id, in request order.
Returns:
one merged plan per state the plain plans bring their names into,
then every other plan.
"""
groups: dict[str, list[list[ModuleCall]]] = {}
for plan in plans:
if _plain(plan):
groups.setdefault(plan[-1].args["state"], []).append(plan)
return [_merge(group) for group in groups.values()] + [
plan for plan in plans if not _plain(plan)
]
def _merge(plain: list[list[ModuleCall]]) -> list[ModuleCall]:
merged: list[ModuleCall] = []
for offset in range(max(len(plan) for plan in plain), 0, -1):
calls = [plan[-offset] for plan in plain if len(plan) >= offset]
names = list(
dict.fromkeys(name for call in calls for name in call.args["name"])
)
merged.append(replace(calls[0], args={**calls[0].args, "name": names}))
return merged