Files
computer-playbook/utils/github/variant/pins.py
T
kevinveenbirkenbachandClaude Opus 5 d9f8af9b16 fix(ci): refuse a mesh pin the row cannot take
A selection token may pin the mesh state as &vpn or &direct, but check_pins
had no arm for it. pinned_vpn_states intersects the pin with the states the
mode allows and only swarm meshes, so a token like @compose+clearnet&vpn left
an empty state list: the row produced no job at all and the run reported green
for a combination that never deployed. That is the outcome _reject exists to
prevent, and every sibling axis already refuses an impossible pin.

The new arm asks whether any candidate mode keeps a state rather than whether
the pin is reachable, so a held vpn axis still overrides the pin instead of
being refused. That behaviour has its own test and is unchanged.

An earlier check was removed with the compose-mesh revert of 2026-10-16
because it rested on meshable() and smallest_variant(), which that revert
deleted. This one reuses pinned_vpn_states, applies to the swarm-only axis the
revert restored, and covers both mesh states instead of only &vpn.

pin_vpn and vpn_mode now reach check_pins from all three callers. The run's
vpn axis reaches the validation job through call-orchestrator and
call-validation, which passed every other axis but this one. The priority
procedure and its skill state all eight axes a token carries, not six:
architecture and mesh were pinnable and, for architecture, already validated,
yet neither was documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-21 18:13:54 +02:00

141 lines
5.1 KiB
Python

"""Prove a selection token's pins against what the row and the run permit.
Separate from :mod:`utils.github.variant.axes`, which decides the axes of a
row that carries no pin: this module only refuses the combinations a pin
asks for and the row cannot give.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from utils.github.variant.pools import ARCHITECTURES
from utils.github.variant.tor import tor_states
from utils.github.variant.vpn import VPN_DEPLOY_MODES, pinned_vpn_states
from utils.roles.display import VARIANT_SEPARATOR
from utils.roles.meta_lookup import get_role_architectures
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["check_pins", "row_architectures"]
def _reject(app: str, variant: str, reason: str) -> None:
"""Abort on a selection the row cannot satisfy.
Raises:
SystemExit: always. A pin the row cannot take is an operator mistake,
and dropping the row instead would report a green run for a
combination that never ran.
"""
shard = f"{VARIANT_SEPARATOR}{variant}" if variant else ""
raise SystemExit(f"selection {app}{shard}: {reason}")
def check_pins(
app: str,
variant: str,
offered: Sequence[str],
*,
pin_mode: str | None,
pin_tor: bool | None,
pin_distro: str | None,
pin_filesystem: str | None,
pin_architecture: str | None = None,
pin_vpn: bool | None = None,
capable: bool,
tor_mode: str,
vpn_mode: str = "auto",
distros: Sequence[str],
filesystems: Sequence[str],
architectures: Sequence[str] = ARCHITECTURES,
) -> None:
"""Prove the row can take what the selection token pinned on it.
The offered modes are already narrowed by the run's ``--modes`` input, the
onion states by its ``--tor`` input, the mesh states by its ``--vpn`` input
and the two pools by ``--distros`` and ``--filesystem``, so this catches a
pin that fights the role, the variant, or the run's own axes with one check
each.
"""
if pin_mode is not None and pin_mode not in offered:
_reject(
app,
variant,
f"pinned mode {pin_mode!r} is not available here "
f"(offered: {', '.join(offered)})",
)
for value, pool, axis in (
(pin_distro, distros, "distro"),
(pin_filesystem, filesystems, "filesystem"),
(pin_architecture, architectures, "architecture"),
):
if value is not None and value not in pool:
_reject(
app,
variant,
f"pinned {axis} {value!r} is outside this run's {axis} pool "
f"({', '.join(pool) or 'empty'})",
)
modes = (pin_mode,) if pin_mode else offered
if pin_tor is not None and not any(
pin_tor in tor_states(mode, capable=capable, tor_mode=tor_mode)
for mode in modes
):
_reject(
app,
variant,
f"pinned onion state {'tor' if pin_tor else 'clearnet'} is impossible "
f"here (mode, variant or the run's tor axis rules it out)",
)
if pin_vpn is not None and not any(
pinned_vpn_states(mode, pin=pin_vpn, vpn_mode=vpn_mode) for mode in modes
):
_reject(
app,
variant,
f"pinned mesh state {'vpn' if pin_vpn else 'direct'} is impossible "
f"here (only {'/'.join(VPN_DEPLOY_MODES)} meshes). A held vpn axis "
f"overrides the pin instead and is not refused here",
)
def row_architectures(
app: str, run_pool: Sequence[str], services: Sequence[str] = ()
) -> tuple[str, ...]:
"""The architectures one role's rows may draw from this run.
A role that declares none takes the run's whole pool, which is what
spreads both architectures evenly over its variants: every row of the
catalogue advances the same rotation, so a role's consecutive rows
alternate rather than repeating one architecture.
Args:
app: role id, read for its ``meta/services.yml`` declaration.
run_pool: what the run permits, already narrowed by its own input.
services: the roles the row's variant deploys, each narrowing the
row like the role's own declaration.
Raises:
SystemExit: the role and the run permit nothing in common. Dropping
the row instead would report a green run for a role that never
deployed, and picking one anyway would deploy it on hardware one
of the two sides ruled out.
"""
allowed = tuple(run_pool)
declarations: list[str] = []
for role in (app, *services):
declared = get_role_architectures(role)
if declared:
declarations.append(f"{role} ({', '.join(declared)})")
allowed = tuple(value for value in allowed if value in declared)
if not allowed:
raise SystemExit(
f"{app}: meta/services.yml declares architectures for "
f"{'; '.join(declarations)}, and this run permits "
f"{', '.join(run_pool) or 'none'}. Widen the run's architecture "
f"pool or the declarations."
)
return allowed