diff --git a/.github/workflows/assert-memory-budget.yml b/.github/workflows/assert-memory-budget.yml new file mode 100644 index 0000000000..3f387f240e --- /dev/null +++ b/.github/workflows/assert-memory-budget.yml @@ -0,0 +1,99 @@ +name: Assert memory budget (composition vs RAM VM, #19805) + +# Issue #19805 : `fix(runners): budget memoire po-2024 incoherent avec la VM -- +# 42 Go declares pour 24 032 Mo disponibles`. La composition cumulee des +# conteneurs runners (effectifs x caps) etait superieure a la RAM de la VM +# WSL, et l'instrumentation existante ne le detectait pas. `measure_po2024_topology` +# mesure la topologie mais ne confronte pas la composition au budget declare, +# et le garde precedent ne connaissait que le budget (pas la RAM VM). +# +# Cet organe `scripts/ci/assert_memory_budget.py` comble le trou. Il derive la +# composition des unites DEPLOYEES (`scripts/ci/docker/linux-runner/persist/` : +# `ExecStart` de chaque jambe, le drop-in de la machine-cible en surcharge), +# prend la RAM VM de la machine-cible comme constante DECLAREE -- jamais mesuree +# sur l'hote qui execute l'organe -- et compare a la borne la plus contraignante +# entre MemoryMax de la slice et la RAM VM (hote reserve). Rouge si la +# composition depasse. +# +# Le verdict sert deux causes : +# 1. Detecter la derive de la composition quand un dimensionnement change sans +# que le reste suive (mesure fondateur de l'issue #19805 : l'agrandissement +# VM du 2026-09-21 a ete revertu sans que les declarations suivent). +# 2. Empecher l'extension silencieuse des caps par conteneur (chaque +# modification de supervise.sh qui touche --memory doit etre accompagnee +# d'un dimensionnement coherent, et celui-ci vit dans les unites). +# +# Workflow bloquant sur pull_request et push (les PRs touchant une unite, un +# drop-in de machine, la slice ou supervise.sh concernes reapparaissent dans le +# diff et le guard rougit). Schedule quotidien en arriere-plan pour detecter les +# derives qui n'ont pas ete portees par une PR. + +on: + pull_request: + paths: + - "docker-configurations/runners/**" + - "scripts/ci/assert_memory_budget.py" + - "scripts/ci/measure_po2024_topology.py" + # Les unites et drop-ins dont la composition est DERIVEE : le guard doit + # se relancer quand l'un d'eux change, sinon il rend un verdict sur un + # dimensionnement qui n'est plus celui du depot. + - "scripts/ci/docker/linux-runner/persist/**" + - "scripts/ci/docker/linux-runner/supervise.sh" + push: + branches: [main] + paths: + - "docker-configurations/runners/**" + - "scripts/ci/assert_memory_budget.py" + - "scripts/ci/measure_po2024_topology.py" + - "scripts/ci/docker/linux-runner/persist/**" + - "scripts/ci/docker/linux-runner/supervise.sh" + schedule: + # Verifie la derive de l'instrument meme quand aucune PR ne touche + # le snapshot (un revert de .wslconfig sur po-2024 lui-meme est + # invisible depuis le repo : la CI sert de rappel hebdomadaire). + - cron: "37 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + assert: + name: Composition vs RAM VM + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Run assert_memory_budget + id: assert + run: | + set -uo pipefail + python scripts/ci/assert_memory_budget.py --json > /tmp/assert.json + rc=$? + echo "exit_code=$rc" >> "$GITHUB_OUTPUT" + cat /tmp/assert.json + if [ $rc -ne 0 ]; then + echo "::error title=Incoherence budget memoire (issue #19805)::La composition cumulee des conteneurs runners depasse la borne la plus contraignante (MemoryMax slice ou RAM VM - hote reserve). Voir /tmp/assert.json et l'issue #19805." + exit 1 + fi + env: + PYTHONIOENCODING: utf-8 + + # Le runner ubuntu-latest n'expose pas pytest dans le Python 3.13 de + # setup-python : sans cette etape le smoke test echoue sur + # `No module named pytest` (rouge fondeur du 2026-10-09), pas sur le code. + - name: Install pytest + run: python -m pip install --quiet --disable-pip-version-check pytest + + - name: Run pytest (smoke) + run: | + python -m pytest scripts/tests/test_assert_memory_budget.py -v + env: + PYTHONIOENCODING: utf-8 diff --git a/docker-configurations/runners/po2024_budget.json b/docker-configurations/runners/po2024_budget.json new file mode 100644 index 0000000000..71aa872ffb --- /dev/null +++ b/docker-configurations/runners/po2024_budget.json @@ -0,0 +1,30 @@ +{ + "machine": "po-2024", + "snapshot_date": "2026-10-08", + "vm_total_gib_observed": 23.47, + "composition": { + "docker": { + "instances": 6, + "cap_gib": 1.5, + "total_gib": 9.0, + "source": "supervise.sh cmd_run --memory 1536m (defaut)" + }, + "waiters": { + "instances": 12, + "cap_gib": 0.5, + "total_gib": 6.0, + "source": "supervise.sh cmd_waiters --memory 512m" + }, + "lean": { + "instances": 2, + "cap_gib": 6.0, + "total_gib": 12.0, + "source": "supervise.sh cmd_lean --memory 6g + memory-swap=memory" + } + }, + "composition_total_gib": 27.0, + "vm_total_gib": 23.47, + "verdict_at_snapshot": "INCOHERENT : 27.0 GiB > 23.47 GiB VM", + "issue": 19805, + "notes": "Snapshot initial issu de la mesure firsthand de l'issue #19805 (2026-10-08). Le budget memoire cumule des conteneurs runners depasse la RAM de la VM WSL (24 032 Mo = 23.47 GiB). La slice coursia-ci.slice plafonne a 16 GiB (MemoryMax) et 12 GiB (MemoryHigh), donc le garde kernel cgroup v2 tient le mur ; ce snapshot sert a l'organe assert_memory_budget pour detecter la derive avant qu'elle ne se reproduise (cf. cause racine issue #19805 : agrandissement VM 2026-09-24 revertu, declarations non revisees)." +} diff --git a/scripts/ci/assert_memory_budget.py b/scripts/ci/assert_memory_budget.py new file mode 100644 index 0000000000..e9f51f2089 --- /dev/null +++ b/scripts/ci/assert_memory_budget.py @@ -0,0 +1,549 @@ +"""Organe de garde -- composition memoire vs RAM de la VM (po-2024 #19805). + +Issue #19805 : `fix(runners): budget memoire po-2024 incoherent avec la VM -- +42 Go declares pour 24 032 Mo disponibles`. Le budget de la CI (somme des +caps par conteneur x effectifs) etait superieur a la RAM de la VM WSL, et +rien dans l'instrumentation existante ne le detectait. `measure_po2024_topology` +mesure la topologie OS mais ne confronte pas la composition au budget +declare, et le garde `assert_memory_budget` precedent ne connaissait que +le budget declare -- il ne pouvait pas rougir sur une composition +incoherente avec la machine. + +Cet organe comble ce trou : +- RAM VM : **declaree** pour la machine-cible (`PO2024_VM_RAM_GIB`), jamais + mesuree sur l'hote qui execute l'organe -- cf. le bloc dedie plus bas. +- Composition : **derivee des unites DEPLOYEES** (`persist//.d/` + puis `persist/`) : effectif = argument entier de l'`ExecStart` du + wrapper de jambe, cap = `Environment=` de la meme unite. L'organe mesure le + deploiement, pas un instantane ecrit a la main ni les valeurs documentees. +- Plafond dur declare : MemoryMax de la slice `coursia-ci.slice`, surcharge + `persist//coursia-ci.slice` d'abord, generique en repli. +- Marge : 0,5 GiB absorbe les arrondis d'unite (1024 vs 1000) et la memoire + reservee au systeme hote (Windows hote / WSL overhead). + +Verdict : +- exit 0 si `composition + marge <= min(MemoryMax, RAM VM)`. +- exit 1 si la composition depasse la borne la plus contraignante + (plafond slice declare ou RAM VM reelle). Sortie JSON sur stdout + + un resume 1 ligne sur stderr. + +Le script n'invoque pas docker, ne touche pas a la machine cible, et peut +tourner depuis n'importe quelle machine du cluster (cross-platform via +`measure_po2024_topology`). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DEFAULT_BUDGET = REPO_ROOT / "docker-configurations" / "runners" / "po2024_budget.json" +DEFAULT_SLICE = REPO_ROOT / "scripts" / "ci" / "docker" / "linux-runner" / "persist" / "coursia-ci.slice" +DEFAULT_SUPERVISE = REPO_ROOT / "scripts" / "ci" / "docker" / "linux-runner" / "supervise.sh" +PERSIST_DIR = REPO_ROOT / "scripts" / "ci" / "docker" / "linux-runner" / "persist" + +# Les trois jambes du pool CI, telles que le deploiement les installe : +# (nom de famille, unite systemd, script du wrapper, variable de cap memoire, +# cap par defaut en MiB si l'unite ne le declare pas). +# +# L'effectif N n'est PAS dans une constante : il est lu dans l'argument entier +# de l'`ExecStart=` de l'unite. C'est le point de la revue coordinateur du +# 2026-10-08 (defaut 1) : le meme nombre ecrit a la main dans un snapshot ou +# dans un en-tete de documentation derive silencieusement du deploiement. +RUNNER_LEGS: tuple[tuple[str, str, str, str, int], ...] = ( + ("start", "coursia-runner.service", "coursia-runner-start.sh", + "COURSIA_RUNNER_MEMORY", 1536), + ("waiters", "coursia-waiters.service", "coursia-waiters-start.sh", + "COURSIA_RUNNER_WAITER_MEMORY", 512), + ("lean", "coursia-lean.service", "coursia-lean-start.sh", + "COURSIA_LEAN_RUNNER_MEMORY", 6 * 1024), +) + +# Marge de securite en GiB : arrondis d'unite (1 GiB = 1024 MiB mais +# declare parfois comme 1000 MB) + memoire reservee a l'hote (Windows + +# WSL overhead, ~200-400 MiB mesures). +DEFAULT_MARGE_GIB = 0.5 + +# Memoire pour les services NON-CI sur l'hote Windows. La slice plafonne +# a 16 Go, ce qui laisse >100 Go a l'hote (cf. commentaire en tete de +# coursia-ci.slice). On accepte ce partage : l'organe ne rougit que sur +# la composition CI vs la borne la plus contraignante. +HOST_RESERVED_GIB = 0.5 + +# RAM VM de la machine-CIBLE, declaree comme constante (issue #19805 revue +# coordinateur 2026-10-08, defaut 3). +# +# Pourquoi constante, pas mesuree : l'organe peut tourner depuis N'IMPORTE +# QUELLE machine du cluster (ai-01 191.8 GiB, po-2027 63.6 GiB, po-2024 ~24 +# GiB) et c'est la machine-CIBLE qui plafonne, pas celle qui execute. Mesurer +# la RAM de l'hote de l'organe rend un verdict incoherent : un organe sur +# ai-01 dirait "VM po-2024 = 191.8 GiB, composition 27 GiB, OK" alors que +# la VM reelle est 24 GiB -- c'est exactement le defaut fondateur que +# `assert_memory_budget` est cense fermer. +# +# Source de verite : PR #19802 (allocation VM 24 GiB sur po-2024). Si une +# autre machine-cible est ajoutee, etendre ce mapping. +PO2024_VM_RAM_GIB = 24.0 +_MACHINE_VM_RAM_GIB: dict[str, float] = { + "po-2024": PO2024_VM_RAM_GIB, +} + + +def lookup_vm_ram_gib(machine: str) -> float | None: + """RAM VM declaree pour la machine-cible, ou None si inconnue. + + L'organe refuse de mesurer l'hote sur lequel il tourne (cf. PO2024_VM_RAM_GIB). + La RAM est une propriete de la machine VERIFIEE, pas de la machine + EXECUTANT l'organe. + """ + return _MACHINE_VM_RAM_GIB.get(machine) + + +def lookup_slice(machine: str) -> Path: + """Cherche la surcharge de slice pour la machine, fallback sur la generique. + + #19802 a introduit le motif `persist//coursia-ci.slice` pour + permettre un plafond memoire DIFFERENT par machine-cible. La generique + `persist/coursia-ci.slice` reste le fallback historique. + """ + machine_slice = ( + REPO_ROOT + / "scripts" + / "ci" + / "docker" + / "linux-runner" + / "persist" + / machine + / "coursia-ci.slice" + ) + if machine_slice.is_file(): + return machine_slice + return DEFAULT_SLICE + + +def _deployed_unit_text(machine: str, unit: str, persist_dir: Path) -> tuple[str, list[Path]]: + """Texte de l'unite telle que DEPLOYEE pour `machine`, + les fichiers lus. + + Ordre systemd : le drop-in de la machine est lu APRES l'unite et peut la + surcharger ; on retourne donc les deux, le drop-in en dernier, et + l'appelant resout « derniere definition gagne ». Un drop-in qui ne + redefinit pas un champ ne l'efface pas -- d'ou la concatenation plutot + qu'un choix exclusif. + """ + parts: list[str] = [] + read: list[Path] = [] + base = persist_dir / unit + if base.is_file(): + parts.append(base.read_text(encoding="utf-8")) + read.append(base) + dropin_dir = persist_dir / machine / f"{unit}.d" + if dropin_dir.is_dir(): + for dropin in sorted(dropin_dir.glob("*.conf")): + parts.append(dropin.read_text(encoding="utf-8")) + read.append(dropin) + return "\n".join(parts), read + + +def _execstart_instances(text: str, script: str) -> int | None: + """Effectif N lu dans l'`ExecStart` du wrapper `script`. + + Formes rencontrees dans le deploiement : `...