Files
computer-playbook/cli/administration/deploy/development/inventory/legacy_resolver.py
T
kevinveenbirkenbachandClaude Opus 5 50091194d5 fix(deploy): keep a non-invokable prerequisite out of the round's app list
Every desktop job of run 36246124624 aborted before ansible started:

    [ERROR] Some application_ids are invalid for this inventory:
      - user-workstation: not present in inventory

The include list the planner returns becomes two things at once: the
inventory provisioner's --include, and the deploy ids validate_application_ids
checks against the resulting inventory. The inventory is built from
get_all_invokable_apps, and utils/roles/stage.py loops a stage only over
invokable category paths, so a non-invokable id in that list is one no group
can hold and no stage can dispatch. `user-workstation` landed there because
the desktop-base service edge pulls it into the closure.

Measured over all 179 invokable roles, old resolution against new:

    rounds losing a member: 33
    distinct dropped roles: ['dev-java', 'user-workstation']

`user-workstation` in all 33 dsk rounds, `dev-java` in dsk-bluray-player,
which carries it as a meta dependency and would have aborted the same way
on main the day a round picked that role.

Neither stops running. A meta dependency resolves at role-include time,
independent of the inventory. `user-workstation` reaches its host through
the service loader, whose preload closure is computed from the filtered
whitelist and still contains it, and through dsk-base's own include_role:

    dsk-firefox: include=['dsk-base', 'dsk-firefox']
                 user-workstation in preload closure=True
                 workstation service required=True

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 23:06:58 +02:00

124 lines
4.8 KiB
Python

"""Default (non-variant) include resolution for the matrix planner.
When NO primary app ships a `meta/variants.yml`, the planner falls back
to this path — the `CombinedResolver` walks `dependencies` and
`shared:`-edges in the variant-merged services map and Jinja flags are
evaluated at deploy time against `group_names`. `run_after` is NOT
followed for inclusion (it is a pure ordering hint), so a variant that
disables a service does not get the provider re-added through the
ordering edge. This module owns the include resolution that path needs
and nothing else; the round's variant-merged services maps come from
`utils.roles.applications.variants`, which the CI selection resolves its
closure from as well.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from utils.roles.applications.variants import services_overrides_for_round
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
_build_services_overrides_for_round = services_overrides_for_round
def _resolve_round_include(
*,
primary_apps: Sequence[str],
services_overrides: dict[str, dict],
) -> tuple[str, ...]:
"""Resolve transitive prerequisites for each primary app under a
round's variant-merged services map. Returns the full include set
in stable order (deps first per primary, primary last; primaries
iterated in the user-provided order).
A non-invokable prerequisite is dropped from the result. The include
set becomes an inventory group per entry and a deploy id the play
dispatches, and `utils.roles.stage` only ever loops over invokable
paths, so a non-invokable id could never be dispatched from one.
Keeping it would abort the round before ansible starts, which is
what `user-workstation` did to every desktop round, and inventing a
group for it instead would produce one nothing dispatches. The role
still reaches the host through whatever pulled it into the closure:
a meta dependency, or the service edge `sys-service-loader` reads.
The resolver import is deferred so the host-side import surface
stays lean for callers that never need the variant-aware planner
(e.g. lint/validation).
"""
from cli.meta.roles.applications.resolution.combined.resolver import (
CombinedResolver,
)
from utils.roles.required_by_coverage import role_is_invokable
resolver = CombinedResolver(
services_overrides=services_overrides, follow_run_after=False
)
out: list[str] = []
seen: set[str] = set()
for app_id in primary_apps:
deps = resolver.resolve(app_id)
for dep in deps:
if dep != app_id and dep not in seen and role_is_invokable(dep):
out.append(dep)
seen.add(dep)
if app_id not in seen:
out.append(app_id)
seen.add(app_id)
return tuple(out)
def prune_orphans_after_disable(
*,
include: Sequence[str],
primary_apps: Sequence[str],
disabled_app_ids: Iterable[str],
services_overrides: dict[str, dict],
) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Split a round's ``include`` into ``(kept, pruned)`` after `disable`.
`kept` are the members still reachable from a surviving primary app
once every role in `disabled_app_ids` is cut from the graph; `pruned`
are the orphaned remainder (the disabled roots themselves excluded).
Reachability walks the same `dependencies` + `services` edges the
round's closure was built from — `run_after` is NOT followed, matching
``CombinedResolver(follow_run_after=False)`` — using `services_overrides`
so the variant-merged topology matches what the inventory bakes.
Returned order follows `include` for both tuples.
Args:
include: the round's full closure (deps-first, primary last).
primary_apps: the operator-selected roots (already `disable`-filtered).
disabled_app_ids: provider roles cut by `disable`.
services_overrides: round variant-merged services maps per role.
"""
from cli.meta.roles.applications.resolution.combined.resolver import (
CombinedResolver,
)
disabled = set(disabled_app_ids)
resolver = CombinedResolver(
services_overrides=services_overrides, follow_run_after=False
)
reachable: set[str] = set()
stack = [a for a in primary_apps if a not in disabled]
while stack:
node = stack.pop()
if node in reachable or node in disabled:
continue
reachable.add(node)
edges = resolver.edges_for(node)
stack.extend(
dep
for dep in (*edges.dependencies, *edges.services)
if dep not in reachable and dep not in disabled
)
kept = tuple(role for role in include if role in reachable)
pruned = tuple(
role for role in include if role not in reachable and role not in disabled
)
return kept, pruned