Files
computer-playbook/cli/meta/ci/plan.py
evangelostsak c839dd07ff fix(ci): accept the mesh axis in the plan renderer
The discovery step builds its table with plan.py, not matrix.py, so passing
--vpn aborted the chunk before a runner was spent on it. The plan now takes
the flag and shows the state as a column, the way it already shows the onion.
2026-09-26 09:48:09 +02:00

215 lines
7.4 KiB
Python

"""Render the plan of one CI sweep: every candidate row, and where it lands.
Usage:
python -m cli.meta.ci.plan [--distros "debian"] [--filesystem "zfs"]
[--whitelist "..."] [--priority "..."] [--modes auto]
[--lifecycles "..."] [--sweep N] [--cli]
The plan runs the same pipeline the deploy jobs discover through
(``cli.meta.ci.matrix``), so what the table shows and what CI deploys cannot
diverge. One row per ``role#variant`` candidate in global query order, with
the chunk it falls into and the axes it was assigned.
Status per row: ⭐ a priority row, ✅ deployed by this sweep, ❌ beyond the
sweep's budget (raise ``--offset`` to reach it), ✂️ cut as redundant coverage —
a clone or a row an earlier one already embeds, which no sweep deploys.
``--cli`` renders fixed-width terminal tables instead of Markdown.
📖 marks the row that replays the role's README instructions after its deploy,
and names the mode the replay runs in -- which is the guide's own, not the
row's (:mod:`utils.roles.guide`).
🆔 numbers the rows of this table, 1..n, and 🔰 names the 🆔 of the earlier row
that already embeds this one -- empty when nothing does. A row with a 🔰 is
redundant coverage: whatever it would prove, the row it points at proves first. 🐑 marks the other reason a row is cut: it
shares its service set with an earlier role, which stands in for it.
"""
from __future__ import annotations
import argparse
import os
import sys
from cli.meta.ci import matrix, query, slots
from cli.meta.roles.applications.complexity.render import _dwidth
from utils.github.variant import axes, instructions, pools, tor, vpn
from utils.roles.display import display_names
from utils.symbol_glossary import to_emoji
_STAR = to_emoji("priority")
_OK = to_emoji("enabled")
_OFF = to_emoji("disabled")
_CUT = to_emoji("redundant")
_COLUMNS = (
"id",
"chunk",
"name",
"variant",
"mode",
"distro",
"filesystem",
"tor",
"vpn",
"triggered",
"covered_by",
"instructions",
"clone",
)
_GLYPH = {"triggered": "enabled", "distro": "distros"}
_HEADERS = tuple(
f"{to_emoji(_GLYPH.get(key, key))} {key.replace('_', ' ').capitalize()}"
for key in _COLUMNS
)
def _key(entry: dict[str, str]) -> tuple[str, ...]:
"""What identifies one deploy row: every axis it was assigned.
A priority role runs every combination of its variant, and a selection can
ask for one variant on two distros, so app+variant alone matches several
rows and would report the first one's chunk for all of them -- including
for rows the budget never reaches, which the table would then mark as
deployed.
"""
return (
entry["apps"],
entry["variant"],
entry["mode"],
entry["tor"],
entry["vpn"],
entry["distro"],
entry["filesystem"],
)
def _chunk_of(entry: dict[str, str], plan: list[list[dict[str, str]]]) -> int | None:
"""Index of the chunk *entry* landed in, or ``None`` when this sweep
leaves it to the next one."""
key = _key(entry)
for index, chunk in enumerate(plan):
if any(_key(row) == key for row in chunk):
return index
return None
def cells(
entries: list[dict[str, str]],
plan: list[list[dict[str, str]]],
) -> list[tuple[str, ...]]:
rows = []
positions: dict[str, str] = {}
for counter, entry in enumerate(entries, start=1):
positions.setdefault(entry.get("id", ""), str(counter))
for counter, entry in enumerate(entries, start=1):
chunk = _chunk_of(entry, plan)
if instructions.redundant(entry):
status, where = _CUT, ""
elif chunk is None:
status, where = _OFF, ""
else:
status = _STAR if entry["priority"] == "true" else _OK
where = str(chunk)
covered = entry.get("covered", "0")
rows.append(
(
str(counter),
where,
entry["apps"],
entry["variant"],
to_emoji(entry["mode"]),
to_emoji(entry["distro"]),
to_emoji(entry["filesystem"]),
to_emoji("tor" if entry["tor"] == "true" else "clearnet"),
to_emoji("vpn" if entry["vpn"] == "true" else "direct"),
status,
positions.get(covered, covered) if covered not in ("", "0") else "",
to_emoji(entry["instructions"]) if entry["instructions"] else "",
to_emoji("clone") if entry.get("clone") == "true" else "",
)
)
return rows
def _title(plan: list[list[dict[str, str]]], sweep: int) -> str:
sizes = ", ".join(str(len(chunk)) for chunk in plan) or "-"
return (
f"sweep {sweep} · chunk size {slots.chunk_size()} "
f"· {len(plan)} chunk(s) [{sizes}] · budget {slots.available()}"
)
def render_markdown(title: str, rows: list[tuple[str, ...]]) -> str:
lines = [
"## Plan 🗺️",
"",
f"### {title}",
"",
"| " + " | ".join(_HEADERS) + " |",
"|" + "---|" * len(_HEADERS),
]
lines += ["| " + " | ".join(cell) + " |" for cell in rows]
return "\n".join(lines)
def _pad(value: str, width: int) -> str:
return value + " " * max(width - _dwidth(value), 0)
def render_cli(title: str, rows: list[tuple[str, ...]]) -> str:
widths = [
max([_dwidth(header), *(_dwidth(cell[i]) for cell in rows)])
for i, header in enumerate(_HEADERS)
]
header = " ".join(_pad(h, w) for h, w in zip(_HEADERS, widths, strict=True))
rule = " ".join("-" * w for w in widths)
body = [
" ".join(_pad(value, w) for value, w in zip(cell, widths, strict=True))
for cell in rows
]
return "\n".join([title, header, rule, *body])
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Render the plan of one CI sweep.")
parser.add_argument("--distros", default="")
parser.add_argument("--whitelist", default="")
parser.add_argument("--priority", default="")
parser.add_argument("--modes", default=query.ALL_MODES)
parser.add_argument("--lifecycles", default="")
parser.add_argument("--sweep", type=int, default=None)
parser.add_argument("--tor", default=None)
parser.add_argument("--vpn", default=None)
parser.add_argument("--filesystem", default="")
parser.add_argument("--offset", default=None)
parser.add_argument("--cli", action="store_true")
args = parser.parse_args(argv)
codec = display_names()
if args.lifecycles.strip():
os.environ["INFINITO_LIFECYCLES"] = args.lifecycles
sweep = axes.resolve_sweep() if args.sweep is None else args.sweep
entries = matrix.entries_of(
modes=query.resolve_modes(args.modes),
whitelist=codec.decode_list(args.whitelist),
priority=codec.decode_list(args.priority),
lifecycles=args.lifecycles,
sweep=sweep,
tor_mode=tor.resolve_tor_mode(args.tor),
vpn_mode=vpn.resolve_vpn_mode(args.vpn),
distros=pools.resolve_distros(args.distros),
filesystems=pools.resolve_filesystems(args.filesystem),
)
plan = matrix.chunks_of(entries, matrix.resolve_offset(args.offset))
rows = cells(entries, plan)
render = render_cli if args.cli else render_markdown
print(render(_title(plan, sweep), rows))
return 0
if __name__ == "__main__":
sys.exit(main())