mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
fix(ci): deploy the instructions host in the shape its README documents
A row marked for the instructions replay gets `web-app-docs` appended to its apps, and the planner hands every primary app the round's index. The host therefore took the subject's index, which for a passenger is an arbitrary shape. Measured in run 36558500327: the same appended role deployed dashboard, matomo, prometheus and css in 141 tasks on a round-0 row and in none at all on a round-1 row, purely because of whose round it was riding. The README's Production block describes the bare role, so the host now takes its smallest variant in every round. The index comes from `smallest_variant` rather than a literal, so it follows `meta/variants.yml` instead of drifting from it. The row names the host in an environment variable. `--apps app#variant` would have read better at the call site but needs a new matrix column in both `axes.py` and `cli/meta/ci/plan.py`, while the deploy CLI already reads `disable` from the environment and the four shell layers between the workflow and the CLI pass it through untouched. A pinned app no longer counts toward the round total: a passenger should not lengthen the subject's sweep. That removes an unreachable round rather than coverage, because `deployed_rounds` plans one app at a time and a role with one variant therefore only ever yields `variant: 0` rows, which the workflow pins on every deploy. The host is left alone when it is also the subject. `web-app-docs` carries the replay on its own rows too, where the apps list reads `web-app-docs, web-app-docs` and the round index is the entire point of the sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
16c853545a
commit
adb53392bd
5 files changed
+123
-5
No files matched your search
@@ -352,6 +352,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
apps: ${{ matrix.instructions != '' && format('{0},web-app-docs', matrix.apps) || matrix.apps }}
|
||||
guide_host: ${{ matrix.instructions != '' && 'web-app-docs' || '' }}
|
||||
variant: ${{ matrix.variant }}
|
||||
disable: ${{ matrix.disable || inputs.disable }}
|
||||
ANSIBLE_LOG_PATH: /tmp/ansible-${{ matrix.apps }}.log
|
||||
|
||||
@@ -19,6 +19,7 @@ from cli.administration.deploy.development.inventory import (
|
||||
from cli.administration.deploy.development.variant_select import (
|
||||
add_variant_args,
|
||||
apply_variant_filter,
|
||||
env_guide_host_pin,
|
||||
)
|
||||
from cli.administration.inventory.provision.services_disabler import (
|
||||
find_provider_roles,
|
||||
@@ -119,10 +120,12 @@ def handler(args: argparse.Namespace) -> int:
|
||||
if passthrough and passthrough[0] == "--":
|
||||
passthrough = passthrough[1:]
|
||||
|
||||
roles_dir = str(compose.repo_root / "roles")
|
||||
plan = plan_dev_inventory_matrix(
|
||||
roles_dir=str(compose.repo_root / "roles"),
|
||||
roles_dir=roles_dir,
|
||||
primary_apps=primary_app_ids,
|
||||
base_inventory_dir=str(args.inventory_dir),
|
||||
pinned_variants=env_guide_host_pin(roles_dir, primary_app_ids),
|
||||
)
|
||||
try:
|
||||
plan = apply_variant_filter(plan, args)
|
||||
|
||||
@@ -20,7 +20,7 @@ from utils.cache.applications import get_variants
|
||||
from . import legacy_resolver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from .spec import PlanEntry
|
||||
|
||||
@@ -54,24 +54,45 @@ def plan_dev_inventory_matrix(
|
||||
roles_dir: str,
|
||||
primary_apps: Sequence[str],
|
||||
base_inventory_dir: str,
|
||||
pinned_variants: Mapping[str, int] | None = None,
|
||||
) -> list[PlanEntry]:
|
||||
"""Return ``[(round_index, inventory_dir, round_variants, include, purge_set), ...]`` — per-round variant-closure plus a plan-constant union for the inter-round wipe."""
|
||||
"""Return ``[(round_index, inventory_dir, round_variants, include, purge_set), ...]`` — per-round variant-closure plus a plan-constant union for the inter-round wipe.
|
||||
|
||||
Args:
|
||||
roles_dir: the roles tree the variants are read from.
|
||||
primary_apps: the apps the sweep deploys.
|
||||
base_inventory_dir: the inventory directory each round is written under.
|
||||
pinned_variants: apps that keep one variant in every round instead of
|
||||
following the round index, and the variant each keeps. An app that
|
||||
rides along as a host rather than a subject wants this: the round
|
||||
index belongs to the subject and means nothing for the passenger.
|
||||
"""
|
||||
if not primary_apps:
|
||||
raise ValueError("plan_dev_inventory_matrix: primary_apps must not be empty")
|
||||
|
||||
pins = dict(pinned_variants or {})
|
||||
variants_per_app = get_variants(roles_dir=roles_dir)
|
||||
primary_variant_counts = {
|
||||
app_id: max(1, len(variants_per_app.get(app_id) or [{}]))
|
||||
for app_id in primary_apps
|
||||
}
|
||||
total_rounds = max(primary_variant_counts.values(), default=1)
|
||||
total_rounds = max(
|
||||
(
|
||||
count
|
||||
for app_id, count in primary_variant_counts.items()
|
||||
if app_id not in pins
|
||||
),
|
||||
default=1,
|
||||
)
|
||||
base = str(base_inventory_dir).rstrip("/")
|
||||
|
||||
per_variant_includes: list[tuple[str, ...]] = []
|
||||
primary_round_variants_per_round: list[dict[str, int]] = []
|
||||
for round_index in range(total_rounds):
|
||||
primary_round_variants = {
|
||||
app_id: round_index if round_index < count else 0
|
||||
app_id: min(pins[app_id], count - 1)
|
||||
if app_id in pins
|
||||
else (round_index if round_index < count else 0)
|
||||
for app_id, count in primary_variant_counts.items()
|
||||
}
|
||||
primary_round_variants_per_round.append(primary_round_variants)
|
||||
|
||||
@@ -11,9 +11,42 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from utils.cache.applications import get_variants
|
||||
from utils.roles.guide import smallest_variant
|
||||
|
||||
from .inventory import filter_plan_to_variants
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def env_guide_host_pin(roles_dir: str, primary_apps: Sequence[str]) -> dict[str, int]:
|
||||
"""Pin the instructions host to the variant its README documents.
|
||||
|
||||
The replay's host rides on another role's deploy row and would otherwise
|
||||
take that row's round index, which for a passenger is an arbitrary shape.
|
||||
The README's Production block describes the bare role, so the host takes
|
||||
its smallest variant in every round instead.
|
||||
|
||||
Args:
|
||||
roles_dir: the roles tree the variants are read from.
|
||||
primary_apps: the apps this deploy was handed.
|
||||
|
||||
Returns:
|
||||
``{host: variant}`` when the ``guide_host`` environment variable names
|
||||
a passenger among *primary_apps* that declares variants, empty
|
||||
otherwise. A round whose only app is the host is that role's own sweep,
|
||||
where the round index is the point and pinning would collapse it.
|
||||
"""
|
||||
host = os.environ.get("guide_host", "").strip()
|
||||
others = {app for app in primary_apps if app != host}
|
||||
if not host or host not in primary_apps or not others:
|
||||
return {}
|
||||
lean = smallest_variant(get_variants(roles_dir=roles_dir).get(host) or [])
|
||||
return {host: int(lean)} if lean else {}
|
||||
|
||||
|
||||
def parse_variant_csv(raw: str) -> list[int]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli.administration.deploy.development.variant_select import env_guide_host_pin
|
||||
|
||||
HOST = "web-app-docs"
|
||||
ROLES = "/repo/roles"
|
||||
|
||||
FAT = {"services": {"dashboard": {"enabled": True}, "matomo": {"enabled": True}}}
|
||||
LEAN = {"services": {"dashboard": {"enabled": False}, "matomo": {"enabled": False}}}
|
||||
|
||||
|
||||
def _variants(mapping):
|
||||
return patch(
|
||||
"cli.administration.deploy.development.variant_select.get_variants",
|
||||
return_value=mapping,
|
||||
)
|
||||
|
||||
|
||||
class TestGuideHostPin(unittest.TestCase):
|
||||
def test_the_host_takes_the_variant_enabling_fewest_services(self) -> None:
|
||||
with (
|
||||
patch.dict("os.environ", {"guide_host": HOST}),
|
||||
_variants({HOST: [FAT, LEAN]}),
|
||||
):
|
||||
self.assertEqual(
|
||||
env_guide_host_pin(ROLES, [HOST, "svc-ai-searxng"]), {HOST: 1}
|
||||
)
|
||||
|
||||
def test_without_the_variable_nothing_is_pinned(self) -> None:
|
||||
with (
|
||||
patch.dict("os.environ", {"guide_host": ""}),
|
||||
_variants({HOST: [FAT, LEAN]}),
|
||||
):
|
||||
self.assertEqual(env_guide_host_pin(ROLES, [HOST]), {})
|
||||
|
||||
def test_a_host_the_round_does_not_deploy_is_not_pinned(self) -> None:
|
||||
"""Pinning an absent app would add it to the plan through the back door."""
|
||||
with (
|
||||
patch.dict("os.environ", {"guide_host": HOST}),
|
||||
_variants({HOST: [FAT, LEAN]}),
|
||||
):
|
||||
self.assertEqual(env_guide_host_pin(ROLES, ["svc-ai-searxng"]), {})
|
||||
|
||||
def test_the_hosts_own_sweep_keeps_its_matrix(self) -> None:
|
||||
"""The docs row carries the replay too, so the host is also the subject
|
||||
there and the round index is the whole point of that sweep."""
|
||||
with (
|
||||
patch.dict("os.environ", {"guide_host": HOST}),
|
||||
_variants({HOST: [FAT, LEAN]}),
|
||||
):
|
||||
self.assertEqual(env_guide_host_pin(ROLES, [HOST, HOST]), {})
|
||||
|
||||
def test_a_host_without_variants_is_not_pinned(self) -> None:
|
||||
with patch.dict("os.environ", {"guide_host": HOST}), _variants({}):
|
||||
self.assertEqual(env_guide_host_pin(ROLES, [HOST]), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user