Files
computer-playbook/cli/administration/deploy/ci/trigger/__main__.py
T
kevinveenbirkenbachandClaude Opus 5 677a63931c Merge branch 'feature/network/multi-network-serving' into feature/store-i18n
Resolves 68 conflicted files. The governing rule was our structure with their
network semantics: the tor boolean becomes a network mode (clearnet | tor |
multi) throughout the variant layer, while this branch's architecture and mesh
axes, its extracted modules and its i18n content stay.

Where the two sides covered different ground, both are kept rather than one
picked:

- ca_injected answers a ca_client on the deployment's TLS mode OR on an SSO
  issuer that resolves self-signed. 3a3e9ed2c9 records why: an issuer is not
  an object store, so keeping only one side drops the other's coverage.
- get_merged_domains keeps the loud refusal when svc-net-tor is deployed
  without a provisioned node, now gated on their tor_serves_host; their
  node_onion_of returns an empty string there, which would have served every
  onion-exclusive app on clearnet in silence.
- sso_cookie_domains stays the single source for oauth2-proxy's cookie scope.
  Their per-network branch omitted the issuer domain on the onion arm, which
  breaks the callback leg.
- The clearnet CSP batch carries --clearnet-timeout again; their batch dict
  hardcoded zero while the role still passes the flag.
- The dashboard keeps its i18n content dir, image, version and design
  resolution; their side reduced the logo sources to the bare service
  provider values.

Their side wins where it is measurably better: the onion mirror rewrite is
gated per vhost rather than per app, the Playwright onion decision reads the
pass's own domain instead of the role's canonical one, and the matomo
reinjection and dashboard config render per network sibling.

Eight files were broken by the merge without ever carrying a conflict marker,
because git marks only the lines both sides touched:

- utils/cache/domains.py lost the onionize block that sat between two hunks
- roles/sys-front-inj-all/templates/body_filter.lua.j2 called it
- web-app-bluesky, keycloak sso_proxy: the get_entity_name filter was renamed
- trigger/__main__.py, playwright_coverage.py and its test: the deleted tor
  module
- cli/administration/deploy/ci/timings.py read Label.tor
- plugins/filter/csp_filters.py called _aligned_url with four of six arguments
- tests/unit/python/utils/cache/test_domains.py referenced a renamed class
- tests/.../sys-ctl-hlth-csp/files/test_script.py mocked a function main no
  longer calls

Verified: the variant layer (155 tests, 65 subtests), ca_injected (18),
the CI layer (175), the domains cache including the restored guard (32),
retrigger inputs (9), timings and the CSP health script (53). make
quality-high clears autoformat; its remaining findings are tracked separately.

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

358 lines
14 KiB
Python

"""Trigger the manual CI run (entry-manual-steer.yml) for the current branch.
entry-manual-steer.yml reads the "__ALL__" whitelist sentinel as "force a full
deploy across all roles".
A retrigger differs from its source run in the selection and in nothing else:
every other dispatch input is carried over (:func:`runs.carried_inputs`, read
off the workflow itself), and the priority line names the exact selections that
failed -- role, variant, deploy mode, network mode and distro -- rather than the
roles they belong to. The filesystem is left to the rotation
(:mod:`cli.administration.deploy.ci.selections` says why). ``--chunk-gate`` is
the one deliberate exception: it overrides the carried value so a retrigger can
keep deploying past a failed chunk.
"""
from __future__ import annotations
import argparse
import sys
from cli.administration.deploy.ci import gh, runs, selections
from cli.meta.ci import matrix, query, validate
from utils.github import run_name
from utils.github.variant import network, pools, selection, vpn
_WORKFLOW = "entry-manual-steer.yml"
_ALL = "__ALL__"
def _fetch(run: str, repo: str) -> dict:
"""The jobs and title of *run*, given as a URL or a bare id (which
resolves against *repo*, the current branch's own)."""
if run.isdigit():
return gh.fetch_run(run, repo=repo)
return gh.fetch_run(gh.run_id_from_url(run), repo=gh.slug_from_url(run))
def _ranking(whitelist: str, config: dict[str, str]) -> list[dict[str, str]]:
"""The regular line the retrigger's own discovery would walk.
The priority line is deliberately left out of that computation: it only
blacklists rows from the regular query, and a token in it that no longer
resolves would abort here, in a helper whose job is to save runner time.
"""
return matrix.entries_of(
modes=query.resolve_modes(config.get("mode") or query.ALL_MODES),
whitelist="" if whitelist == _ALL else whitelist,
priority="",
lifecycles=config.get("lifecycles", ""),
sweep=0,
network_input=network.resolve_network_input(config.get("network")),
vpn_mode=vpn.resolve_vpn_mode(config.get("vpn")),
distros=pools.resolve_distros(config.get("distros")),
filesystems=pools.resolve_filesystems(config.get("filesystem")),
architectures=pools.resolve_architectures(config.get("architectures")),
)
def _refused(whitelist: str, priority: str, config: dict[str, str]) -> bool:
"""Whether the selection cannot deploy on this branch, reported in full.
``call-orchestrator.yml`` already runs the same check as its first job and
every other job needs it, so a bad token never reaches a chunk. What it
does reach is the branch's concurrency group, which
``entry-manual-steer.yml`` declares with ``cancel-in-progress``: dispatching
a run that cannot survive validation first **cancels the run currently in
flight** on that branch. That is the cost this refusal exists to avoid, not
the deploy minutes.
Warnings are printed and do not refuse: a whitelist legitimately names
roles the lifecycle envelope filters out.
Args:
whitelist: the whitelist input, empty for the diff or the all-sentinel.
priority: the priority line as dispatched.
config: the dispatch inputs the axes are resolved from.
"""
errors: list[str] = []
warnings: list[str] = []
for label, tokens in (("whitelist", whitelist), ("priority", priority)):
found, warned = validate.problems(
tokens,
modes=query.resolve_modes(config.get("mode") or query.ALL_MODES),
network_input=network.resolve_network_input(config.get("network")),
vpn_mode=vpn.resolve_vpn_mode(config.get("vpn")),
distros=pools.resolve_distros(config.get("distros")),
filesystems=pools.resolve_filesystems(config.get("filesystem")),
lifecycles=config.get("lifecycles", ""),
label=label,
)
errors += found
warnings += warned
for warning in warnings:
print(f"warning: {warning}")
for error in errors:
print(f"error: {error}", file=sys.stderr)
if errors:
print(
f"\n{len(errors)} unusable selection(s); not dispatching. The run "
"would cancel the one currently in flight on this branch and then "
"fail its own validation job.",
file=sys.stderr,
)
return bool(errors)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
prog="infinito administration deploy ci trigger",
description=(
"Dispatch the manual CI workflow for the branch you are on. "
"Default: trigger every role. With --failed: the roles that "
"failed in the last run form the priority line and the full "
"run follows once they are green. With --apps: an explicit "
"role list as whitelist. Whenever a source run is read, its "
"configuration is carried over so the retrigger reproduces it."
),
)
group = p.add_mutually_exclusive_group()
group.add_argument(
"--failed",
nargs="?",
const=True,
default=None,
metavar="(ignored)",
help=(
"Re-trigger what was not green in the source run as the priority "
"line, together with the priority entries that run never deployed "
"at all; the remaining roles follow once they succeed, starting "
"behind the green stretch the run added to the offset it was "
"given. Every mode is read, and each failed job comes back as the "
"exact selection that failed -- variant, deploy mode and onion "
"state included. A leftover scope argument is accepted and "
"ignored."
),
)
group.add_argument(
"--apps",
default=None,
metavar='"app1 app2 ..."',
help="Explicit space-separated role ids to trigger.",
)
p.add_argument(
"--run",
default=None,
metavar="URL|ID",
help=(
"Run URL or bare run id to reproduce: its configuration (distros, "
"mode, lifecycles, filesystem, architectures, chunk_gate, "
"workspace, instructions) is carried over, and with --failed its "
"results also pick the apps. A bare id resolves against the current "
"branch's repo. With --failed and no --run: the latest deploy run "
"on the current branch."
),
)
p.add_argument(
"--strict",
action="store_true",
help=(
"With --failed: re-trigger only roles with a hard failure (❌), "
"not cancelled/aborted (🚫) or still-running (⏳). Those two never "
"reached a verdict, so the regular line owes them their turn "
"rather than the priority line owing them a place."
),
)
p.add_argument(
"--roles-only",
action="store_true",
help=(
"With --failed: put the roles that failed outright on the priority "
"line by name, letting the rotation assign the axes, instead of "
"replaying the exact combination each job failed in. Useful when "
"so much is red that covering the role matters more than "
"reproducing the row. Only a hard failure collapses: without "
"--strict a cancelled or still-running job comes back as the exact "
"combination it never finished, and a priority entry the source "
"run never deployed keeps its pins either way -- that run holds no "
"evidence against the axes they named."
),
)
p.add_argument(
"--chunk-gate",
choices=("true", "false"),
default=None,
help=(
"Override the carried chunk_gate input. 'false' keeps deploying "
"the remaining chunks after a failed one instead of stopping the "
"chain; 'true' stops at the first failed chunk. Omitted: the "
"source run's value, else the workflow default."
),
)
p.add_argument(
"--priority",
default=None,
metavar='"role#0@compose+clearnet%debian ..."',
help=(
"Lead the run with these selection tokens. Unlike --failed, which "
"derives them from a run's verdicts, this states them: a "
"verification run names the combination it wants to see, axis by "
"axis. Composes with --apps; --failed builds its own line."
),
)
p.add_argument(
"--workspace",
choices=("auto", "true", "false"),
default=None,
help=(
"Override the carried workspace input. 'false' leaves the "
"workspace test out, which a run that only has to reach a deploy "
"verdict does not need."
),
)
p.add_argument(
"--mode",
choices=("auto", "host", "compose", "swarm"),
default=None,
help="Override the carried deploy mode pool.",
)
p.add_argument(
"--tor",
choices=("auto", "enforced", "exclusive", "disabled"),
default=None,
help="Override the carried tor axis.",
)
p.add_argument(
"--distros",
default=None,
metavar='"debian arch"',
help="Override the carried distro pool.",
)
p.add_argument(
"--filesystem",
default=None,
metavar='"ext4 btrfs"',
help=(
"Override the carried filesystem pool. A pool of exactly one is a "
"human naming the kind, so a host whose kernel cannot serve it "
"fails the row instead of substituting one."
),
)
args = p.parse_args(argv)
if args.strict and args.failed is None:
p.error("--strict only applies with --failed")
if args.roles_only and args.failed is None:
p.error("--roles-only only applies with --failed")
if args.priority is not None and args.failed is not None:
p.error("--priority and --failed both build the priority line")
branch = gh.current_branch()
repo = gh.resolve_repo()
source = None
if args.run:
source = _fetch(args.run, repo)
elif args.failed is not None:
run = runs.find_last_deploy_run(branch, repo=repo)
if run is None:
print(
f"No CI run with deploy jobs found on {repo}@{branch}.",
file=sys.stderr,
)
return 1
source = {"jobs": run["_jobs"], "displayTitle": run.get("displayTitle", "")}
whitelist = ""
priority = ""
priority_entries: set[str] = set()
if args.apps is not None:
apps = " ".join(args.apps.split())
if not apps:
p.error("--apps was empty")
whitelist = apps
elif args.failed is not None:
statuses = runs.parse_role_statuses(source["jobs"])
failed = selections.failed_selections(source["jobs"], strict=args.strict)
if args.roles_only:
failed = selections.collapse_failures(
failed, selections.failed_selections(source["jobs"], strict=True)
)
untriggered = runs.untriggered_priority(
runs.dispatched_priority(source, repo), statuses
)
if not failed and not untriggered:
print("Nothing failed in that run; not triggering.")
return 0
if untriggered:
print(f"Priority roles that never deployed: {' '.join(untriggered)}")
priority_entries = set(failed) | set(untriggered)
else:
whitelist = _ALL
config: dict[str, str] = {}
carried_offset = ""
if source is not None:
logged = runs.inputs_from_jobs(source["jobs"], repo)
config = runs.config_from_run(
source["displayTitle"], logged, jobs=source["jobs"]
)
carried_offset = logged.get("offset") or run_name.value_from_title(
source["displayTitle"], "offset"
)
carried_whitelist = config.pop("whitelist", "")
if not whitelist and carried_whitelist:
whitelist = carried_whitelist
for name, value in (
("chunk_gate", args.chunk_gate),
("workspace", args.workspace),
("mode", args.mode),
("tor", args.tor),
("distros", args.distros),
("filesystem", args.filesystem),
):
if value is not None:
config[name] = value
if args.priority is not None:
priority_entries = set(args.priority.split())
if not priority_entries:
p.error("--priority was empty")
priority = " ".join(sorted(priority_entries))
if args.failed is not None:
ranking = _ranking(whitelist, config)
priority = " ".join(sorted(priority_entries))
config["offset"] = selections.resume_offset(
ranking,
selections.proven_rows(source["jobs"]),
selection.parse_list(priority),
carried=carried_offset,
)
if config["offset"]:
print(f"Regular line resumes at: {config['offset']}")
else:
config.pop("offset")
if _refused(whitelist if whitelist != _ALL else "", priority, config):
return 1
if priority:
label = f"priority {priority}, then the remaining roles"
elif whitelist == _ALL:
label = "all roles"
else:
label = whitelist
carried = ", ".join(f"{name}={value}" for name, value in config.items())
print(f"Triggering {_WORKFLOW} on {repo}@{branch} for: {label}")
if carried:
print(f"Carrying over from the source run: {carried}")
runs.dispatch_workflow(
_WORKFLOW, branch, whitelist, priority=priority, config=config, repo=repo
)
print("Dispatched. Watch with: infinito administration deploy ci status")
return 0
if __name__ == "__main__":
raise SystemExit(main())