mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
Merge upstream/main into feature/role-stalwart-mail-swarm
This commit is contained in:
971 files changed
+24099
-4900
No files matched your search
@@ -0,0 +1,178 @@
|
||||
"""Recover a manual CI run's inputs from its run name.
|
||||
|
||||
``entry-manual-steer.yml`` declares a ``run-name:`` that interpolates the dispatch
|
||||
inputs. Once a run has started, that title is the only record of them: the
|
||||
REST API answers ``inputs: null`` for a workflow_dispatch run. Anything that
|
||||
wants to know what a run was dispatched with therefore parses the title — and
|
||||
parsing it against a hand-copied format would drift the moment the workflow
|
||||
changes, silently and without a failing test.
|
||||
|
||||
So the format is read from the workflow itself. Every input renders as one
|
||||
segment, ``<head><value> ``, where the head is the emoji the workflow's own
|
||||
``format('🐧{0} ', …)`` prefixes the value with. An input holding its default
|
||||
renders no segment at all, so a title carries only what deviates — and an
|
||||
absent input means "the workflow's default", which is exactly what a
|
||||
retrigger should leave alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from utils.cache.files import PROJECT_ROOT
|
||||
from utils.cache.yaml import load_yaml
|
||||
|
||||
WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "entry-manual-steer.yml"
|
||||
|
||||
VALUE_GLYPHS = {"是": "true", "否": "false", "序": "serial", "并": "parallel"}
|
||||
|
||||
_EXPRESSION = re.compile(r"\$\{\{(.*?)\}\}", re.DOTALL)
|
||||
_QUOTED = re.compile(r"'([^']*)'")
|
||||
_COMPARED = re.compile(r"[!=]=\s*'([^']*)'")
|
||||
_INPUT = re.compile(r"inputs\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
_PLACEHOLDER = "{0}"
|
||||
|
||||
|
||||
def template() -> str:
|
||||
"""The raw ``run-name:`` string declared by the manual CI workflow."""
|
||||
declared = load_yaml(str(WORKFLOW)).get("run-name")
|
||||
if not declared:
|
||||
raise ValueError(f"{WORKFLOW} declares no run-name")
|
||||
return str(declared)
|
||||
|
||||
|
||||
def _split(tpl: str) -> tuple[list[str], list[str]]:
|
||||
"""Alternating pieces of a run-name template.
|
||||
|
||||
Args:
|
||||
tpl: run-name template text.
|
||||
|
||||
Returns:
|
||||
``(literals, expressions)`` with ``len(literals) ==
|
||||
len(expressions) + 1``; ``literals[i]`` precedes ``expressions[i]``.
|
||||
"""
|
||||
cursor = 0
|
||||
literals: list[str] = []
|
||||
expressions: list[str] = []
|
||||
for match in _EXPRESSION.finditer(tpl):
|
||||
literals.append(tpl[cursor : match.start()])
|
||||
expressions.append(match.group(1).strip())
|
||||
cursor = match.end()
|
||||
literals.append(tpl[cursor:])
|
||||
return literals, expressions
|
||||
|
||||
|
||||
def segments(tpl: str | None = None) -> list[tuple[str, str, bool]]:
|
||||
"""``(input name, opening literal, is_marker)`` per rendered segment, in
|
||||
template order.
|
||||
|
||||
A value segment interpolates its input through a ``format()``
|
||||
placeholder and opens with the format string up to ``{0}``. A marker
|
||||
segment carries no value at all: its literal shows only that the input
|
||||
was set, and its absence is the other state.
|
||||
"""
|
||||
tpl = template() if tpl is None else tpl
|
||||
_literals, expressions = _split(tpl)
|
||||
out: list[tuple[str, str, bool]] = []
|
||||
for expression in expressions:
|
||||
names = set(_INPUT.findall(expression))
|
||||
if len(names) != 1:
|
||||
continue
|
||||
quoted = _QUOTED.findall(expression)
|
||||
placeholder = next((q for q in quoted if _PLACEHOLDER in q), None)
|
||||
if placeholder is not None:
|
||||
out.append((names.pop(), placeholder.split(_PLACEHOLDER, 1)[0], False))
|
||||
continue
|
||||
compared = set(_COMPARED.findall(expression))
|
||||
literal = next(
|
||||
(q.strip() for q in quoted if q.strip() and q not in compared), ""
|
||||
)
|
||||
if literal:
|
||||
out.append((names.pop(), literal, True))
|
||||
return out
|
||||
|
||||
|
||||
def heads(tpl: str | None = None) -> dict[str, str]:
|
||||
"""Input name -> the literal its value segment opens with, template order."""
|
||||
return {name: literal for name, literal, marker in segments(tpl) if not marker}
|
||||
|
||||
|
||||
def markers(tpl: str | None = None) -> dict[str, str]:
|
||||
"""Input name -> the literal its marker segment renders when set."""
|
||||
return {name: literal for name, literal, marker in segments(tpl) if marker}
|
||||
|
||||
|
||||
def openings(tpl: str | None = None) -> set[str]:
|
||||
"""Every literal a rendered segment can start with, so any of them ends
|
||||
the value before it.
|
||||
|
||||
Comparison operands (``inputs.workspace != 'auto'``) and the glyphs a
|
||||
value renders as are excluded: they are what the template tests and emits,
|
||||
never what a segment opens with.
|
||||
"""
|
||||
tpl = template() if tpl is None else tpl
|
||||
literals, expressions = _split(tpl)
|
||||
found: set[str] = {literal.strip() for literal in literals}
|
||||
for expression in expressions:
|
||||
compared = set(_COMPARED.findall(expression))
|
||||
for quoted in _QUOTED.findall(expression):
|
||||
if quoted in compared or quoted in VALUE_GLYPHS:
|
||||
continue
|
||||
found.add(quoted.split(_PLACEHOLDER, 1)[0].strip())
|
||||
return {opening for opening in found if opening}
|
||||
|
||||
|
||||
def value_from_title(title: str, input_name: str, tpl: str | None = None) -> str:
|
||||
"""Value ``input_name`` held when the run named *title* was dispatched.
|
||||
|
||||
Returns an empty string when the title carries no segment for the input —
|
||||
it was dispatched with the workflow's default, or the title comes from
|
||||
another entry point entirely and records nothing at all.
|
||||
|
||||
Raises:
|
||||
ValueError: the run name renders no segment for this input, so no
|
||||
title could ever carry its value.
|
||||
"""
|
||||
tpl = template() if tpl is None else tpl
|
||||
all_heads = heads(tpl)
|
||||
if input_name not in all_heads:
|
||||
raise ValueError(f"inputs.{input_name} renders no segment in: {tpl}")
|
||||
head = all_heads[input_name]
|
||||
start = title.find(head)
|
||||
if start == -1:
|
||||
return ""
|
||||
rest = title[start + len(head) :]
|
||||
cuts = [
|
||||
found
|
||||
for found in (
|
||||
rest.find(opening) for opening in openings(tpl) - {head} if opening
|
||||
)
|
||||
if found != -1
|
||||
]
|
||||
if cuts:
|
||||
rest = rest[: min(cuts)]
|
||||
value = " ".join(rest.split())
|
||||
return VALUE_GLYPHS.get(value, value)
|
||||
|
||||
|
||||
def values_from_title(title: str, tpl: str | None = None) -> dict[str, str]:
|
||||
"""Every input the run named *title* records, keyed by input name.
|
||||
|
||||
Value inputs left on their default render no segment and are absent. A
|
||||
marker input is always reported, ``'true'`` or ``'false'`` by the
|
||||
marker's presence — which is why a title that does not open with the run
|
||||
name's own prefix yields nothing at all rather than a title-wide 'false'.
|
||||
"""
|
||||
tpl = template() if tpl is None else tpl
|
||||
literals, _expressions = _split(tpl)
|
||||
if not title.startswith(literals[0].strip()):
|
||||
return {}
|
||||
found = {name: value_from_title(title, name, tpl) for name in heads(tpl)}
|
||||
found = {name: value for name, value in found.items() if value}
|
||||
found.update(
|
||||
{
|
||||
name: "true" if literal in title else "false"
|
||||
for name, literal in markers(tpl).items()
|
||||
}
|
||||
)
|
||||
return found
|
||||
Whitespace-only changes.
@@ -10,9 +10,11 @@ would then run too long. Roles that fit one runner stay a single entry but still
|
||||
carry their full ``0,…,N-1`` variant CSV, so every job name shows the variants it
|
||||
covers.
|
||||
|
||||
Each entry is ``{"apps": <id>, "variant": "<csv>", "variant_slug": "<dashed>"}``;
|
||||
``variant`` is empty only for a role that declares no variants (a plain base
|
||||
deploy). The ``variant`` slice is threaded
|
||||
Each entry is ``{"apps": <id>, "variant": "<csv>", "variant_slug": "<dashed>",
|
||||
"label": "<display name>"}``; ``variant`` is empty only for a role that
|
||||
declares no variants (a plain base deploy). ``label`` is the role's display
|
||||
name (``utils.roles.display``) with the variant CSV appended, and is what the
|
||||
deploy jobs title themselves with. The ``variant`` slice is threaded
|
||||
through to ``cli.administration.deploy.development`` via the ``variant``
|
||||
environment variable (consumed by ``--variant``), so a runner only iterates the
|
||||
rounds in its bundle. ``variant_slug`` is a comma-free copy for artifact/job
|
||||
@@ -31,10 +33,11 @@ import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from humanfriendly import parse_size
|
||||
from humanfriendly import InvalidSize, parse_size
|
||||
|
||||
from utils import PROJECT_ROOT
|
||||
from utils.cache.applications import get_variants
|
||||
from utils.github.variant.limits import DEFAULT_BUNDLE_SIZE, DEFAULT_MAX_STORAGE
|
||||
from utils.roles.applications.services.registry import (
|
||||
build_service_registry_from_applications,
|
||||
load_applications_from_roles_dir,
|
||||
@@ -43,17 +46,13 @@ from utils.roles.applications.services.resources import (
|
||||
aggregate,
|
||||
collect_role_resources,
|
||||
)
|
||||
from utils.roles.display import display_names
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_BUNDLE_SIZE = 3
|
||||
# Also a runtime proxy: hosted runners kill jobs at 6h, and heavy multi-round
|
||||
# roles (nextcloud) only fit that wall with <= 2 variants per bundle. Keep the
|
||||
# cap below their lightest 3-variant sum or the greedy packer overshoots.
|
||||
DEFAULT_MAX_STORAGE = "330GB"
|
||||
ROLES_DIR = PROJECT_ROOT / "roles"
|
||||
|
||||
|
||||
@@ -84,7 +83,7 @@ def resolve_max_storage(raw: str | None = None) -> int | None:
|
||||
return None
|
||||
try:
|
||||
return int(parse_size(value))
|
||||
except Exception:
|
||||
except InvalidSize:
|
||||
raise ValueError(
|
||||
f"INFINITO_VARIANT_BUNDLE_MAX_STORAGE must be a size like {DEFAULT_MAX_STORAGE!r}, "
|
||||
f"got {value!r}"
|
||||
@@ -275,7 +274,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
apps = json.loads(raw) if raw.strip() else []
|
||||
if not isinstance(apps, list):
|
||||
raise SystemExit(
|
||||
f"variant_bundles: expected a JSON array of app ids, got "
|
||||
f"variant.bundles: expected a JSON array of app ids, got "
|
||||
f"{type(apps).__name__}"
|
||||
)
|
||||
variants_per_app = get_variants()
|
||||
@@ -295,6 +294,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
max_storage_bytes=resolve_max_storage(),
|
||||
bundle_size_per_app=app_bundle_sizes(apps),
|
||||
)
|
||||
codec = display_names()
|
||||
for entry in entries:
|
||||
entry["label"] = codec.encode(entry["apps"], entry["variant"])
|
||||
print(json.dumps(entries))
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Default bundle limits for the CI deploy-matrix discovery.
|
||||
|
||||
Kept apart from :mod:`utils.github.variant.bundles`, which reaches yaml and
|
||||
humanfriendly at import time: the env handlers that seed these defaults are
|
||||
imported by the .env generator on the bare bootstrap python, where no
|
||||
third-party module exists. This module must stay stdlib-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_BUNDLE_SIZE = 3
|
||||
# Rationale: also a runtime proxy — hosted runners kill jobs at 6h, and heavy
|
||||
# multi-round roles (nextcloud) only fit that wall with <= 2 variants per
|
||||
# bundle. Keep the cap below their lightest 3-variant sum or the greedy packer
|
||||
# overshoots.
|
||||
DEFAULT_MAX_STORAGE = "330GB"
|
||||
Reference in new issue
Block a user