diff --git a/.github/workflows/linux-self-hosted-tests.yml b/.github/workflows/linux-self-hosted-tests.yml index 594d735388..cd06d301c2 100644 --- a/.github/workflows/linux-self-hosted-tests.yml +++ b/.github/workflows/linux-self-hosted-tests.yml @@ -4,10 +4,25 @@ name: Linux Self-Hosted Tests # po-2024. Dispatch-only : ZERO declencheur pull_request/push, donc zero # fan-out CI -- pattern #13097 (windows-self-hosted-tests.yml). # +# PRECONDITION -- A LIRE AVANT TOUT DISPATCH (mesure du 2026-09-01) : +# AUCUN runner ne porte aujourd'hui le label `coursia-linux`. Le registre du +# depot contient un seul runner, `myia-po-2024-fast-guards`, qui est WINDOWS. +# Dispatcher ce workflow maintenant met le job en file INDEFINIMENT -- pas +# d'echec, pas de rouge, une attente muette qu'aucun timeout ne coupe +# (timeout-minutes ne court qu'a partir du DEMARRAGE du job). +# +# Pour l'activer, sur po-2024 : +# docker build -t coursia-linux-runner:2.336.0 scripts/ci/docker/linux-runner/ +# scripts/ci/docker/linux-runner/supervise.sh start 2 +# Verifier AVANT de dispatcher : +# gh api repos/jsboige/CoursIA/actions/runners # --jq '.runners[]|select([.labels[].name]|index("coursia-linux"))|.name' +# Sortie vide = ne pas dispatcher. +# # Cycle de vie : le conteneur porte un runner --ephemeral (AU PLUS UN JOB -# puis desenregistrement natif). Chaque dispatch consomme l'enregistrement -# courant : un dispatch, un job, un conteneur. Ne pas dispatcher deux fois -# de suite sans avoir relance le conteneur (scripts/ci/docker/linux-runner/). +# puis desenregistrement natif). Un dispatch = un job = un conteneur. Le +# superviseur relance un conteneur des que le precedent meurt : N boucles +# = N jobs concurrents, ce qui est ce qui leve le plafond de 1 nomme par le +# census #13378 comme LE facteur limitant. # # Sous-semble pilote volontairement etroit : le guard de policy des runners # self-hosted (python pur, aucune ecriture disque, non critique). Les 93 diff --git a/docs/ci/self-hosted-runners.md b/docs/ci/self-hosted-runners.md index 2246693b97..96aeea3086 100644 --- a/docs/ci/self-hosted-runners.md +++ b/docs/ci/self-hosted-runners.md @@ -1,6 +1,6 @@ # Préparation des runners GitHub Actions auto-hébergés -Cette page décrit les mesures et les garde-fous du chantier #12704. **État au 2026-08-28 : activation partielle sur po-2024** (runner `fast-guards` live, tool-cache seedé — cf section Provisionnement ; ré-enregistrement sans UAC opérationnel), les autres profils du registre restant en préparation. Le dépôt `jsboige/CoursIA` est public : une PR de fork peut contenir du code non fiable. Toute exécution auto-hébergée reste réservée aux branches du dépôt lui-même, avec une garde YAML explicite en plus des réglages GitHub. +Cette page décrit les mesures et les garde-fous du chantier #12704. **État au 2026-09-01 : activation partielle sur po-2024, volet Linux non déployé** (runner `fast-guards` live, tool-cache seedé — cf section Provisionnement ; ré-enregistrement sans UAC opérationnel), les autres profils du registre restant en préparation. Le dépôt `jsboige/CoursIA` est public : une PR de fork peut contenir du code non fiable. Toute exécution auto-hébergée reste réservée aux branches du dépôt lui-même, avec une garde YAML explicite en plus des réglages GitHub. ## Mesurer avant de dimensionner @@ -217,11 +217,38 @@ docker run --rm -d --name coursia-linux-runner \ coursia-linux-runner:2.336.0 ``` -Pas de `--gpus` (aucun passthrough GPU, par design). Le runner `--ephemeral` traite **au plus un job** puis se désenregistre et le conteneur `--rm` disparaît : un dispatch = un job = un conteneur. La concurrence est plafonnée à 1 par construction. +Pas de `--gpus` (aucun passthrough GPU, par design). Le runner `--ephemeral` traite **au plus un job** puis se désenregistre et le conteneur `--rm` disparaît : un dispatch = un job = un conteneur. + +**Ce cycle mono-job est le facteur limitant nommé par le census #13378** — pas la compatibilité des workflows. Un `docker run` isolé plafonne donc la concurrence à 1, ce qui ne débraye rien : c'est le superviseur ci-dessous qui lève ce plafond. + +### Superviseur — le plafond de 1 job se lève par N boucles + +`scripts/ci/docker/linux-runner/supervise.sh` (livré 2026-09-01, finalisation du volet laissé en conception). Un slot = une boucle `while` qui relance un conteneur dès que le précédent meurt ; **N slots = N jobs concurrents**. C'est toute la différence entre le conteneur et le service Windows : côté Windows, chaque ré-enregistrement est une tâche planifiée à orchestrer ; ici c'est un `docker run` de plus, gratuit et parallélisable. + +```bash +docker build -t coursia-linux-runner:2.336.0 scripts/ci/docker/linux-runner/ +scripts/ci/docker/linux-runner/supervise.sh start 2 # 2 slots concurrents +scripts/ci/docker/linux-runner/supervise.sh status +scripts/ci/docker/linux-runner/supervise.sh stop # gracieux : les jobs en cours finissent +``` + +Trois points de conception qui ne sont pas négociables : + +- **La boucle vit sur l'hôte, jamais dans l'image.** Le `registration token` vaut 1 h et est jetable : il en faut un neuf à chaque démarrage de conteneur. Le fetch exige `gh` authentifié avec droit admin sur le dépôt — ces credentials ne descendent jamais dans le conteneur, qui ne reçoit que le token par `-e`, comme l'exige déjà `entrypoint.sh`. +- **L'arrêt est gracieux par défaut** (sentinel `~/.coursia-runner/stop`) : plus aucun conteneur n'est lancé, mais le job en cours va à son terme. Tuer un job en vol produirait un rouge qui ne veut rien dire. +- **N appartient à po-2024, pas à ai-01.** Le défaut est 2, volontairement bas. La clause de souveraineté ci-dessous prime sur toute décision d'élargissement. **Labels dédiés** : le jeu `{self-hosted, coursia-ephemeral, coursia-linux}` (second jeu admis par `check_self_hosted_runner_policy.py`) route uniquement vers le conteneur — un dispatch Windows ne peut jamais y atterrir, un job Linux ne peut jamais atterrir sur un runner Windows. Mélanger les deux jeux reste une violation (`RUNNER_LABELS`). -**État pilote** : `linux-self-hosted-tests.yml` est dispatch-ONLY (zéro fan-out, pattern #13097) et exécute le guard de policy (`test_check_self_hosted_runner_policy.py`, python pur, aucune écriture hors workspace conteneurisé). Les 93 jobs ubuntu-latest du census #13378 restent sur GitHub-hosted tant que la mesure d'empreinte n'a pas porté l'élargissement — décision coordinateur, pas worker. +**État réel mesuré le 2026-09-01 (po-2024, inventaire GitHub firsthand)** : le registre du dépôt ne contient **qu'un seul runner**, `myia-po-2024-fast-guards`, **Windows**. Aucun runner ne porte le label `coursia-linux` — nulle part. L'image n'a jamais été construite. + +Conséquence à écrire noir sur blanc, parce que la version précédente de cette page décrivait la conception comme un état déployé : `linux-self-hosted-tests.yml` (dispatch-ONLY, zéro fan-out, pattern #13097) cible un **label orphelin**. Un dispatch y mettrait le job en file **indéfiniment** — pas d'échec, pas de rouge, une attente muette. Le piège est aujourd'hui dormant (0 run en file, vérifié), il ne l'est plus dès qu'on dispatche. + +Le `docker build` + `supervise.sh start` ci-dessus est donc la **précondition** du pilote, pas son prolongement. Tant qu'aucun job n'a rendu la preuve d'identité (`RUNNER_OS = Linux` dans les logs), aucun vert de cette chaîne ne prouve quoi que ce soit — leçon po-2024 du run 33178577527, où l'échec s'était produit *pour la mauvaise raison* (ACE manquante) et aurait pu passer pour un succès de routage. + +Les 93 jobs `ubuntu-latest` du census #13378 restent sur GitHub-hosted tant que l'empreinte n'a pas été mesurée sur des jobs réels — décision coordinateur, pas worker. + +**Le gestionnaire ne bloque pas.** `manage_self_hosted_runner.py` et `self_hosted_runner_profiles.json` sont Windows-only *par validation* (« must pin an official Windows x64 archive ») : ils ne peuvent pas porter un profil Linux aujourd'hui. Ce n'est pas un blocage — `supervise.sh` fonctionne sans eux, sans aucune PR préalable. Étendre le gestionnaire aux profils Linux est une **PR de suivi**, jamais la condition du débrayage. Si l'empreinte mesurée pendant un job gêne la workstation (training GPU, sessions interactives), on **réduit les caps ou on arrête**, et on le signale à ai-01. @@ -238,11 +265,14 @@ La préparation complète reste découpée : État au 2026-08-28 : les tranches 1-3 sont livrées ; la tranche 4 est active sur po-2024 (jobs réels consommés par le pool `coursia-fast-guards`, ex. runs 33092567324 et 33093119578) ; la preuve contrôlée complète (5) et l'extension du pool aux autres machines restent à faire. Chaque extension machine exige le provisionnement Python de la section dédiée avant le premier job. -| Profil du registre | État au 2026-08-28 | +| Profil du registre | État — inventaire GitHub firsthand du 2026-09-01 | |---|---| -| `myia-po-2023-fast-guards` | en préparation (pas de runner installé) | -| `myia-po-2024-fast-guards` | **actif** — tool-cache seedé (a2), ré-enregistrement sans UAC, jobs réels consommés | -| `myia-po-2025-fast-guards` | en préparation (pas de runner installé) | -| `myia-po-2026-fast-guards` | en préparation (pas de runner installé ; profil vérifié dans le registre) | +| `myia-po-2023-fast-guards` | en préparation (aucun runner enregistré) | +| `myia-po-2024-fast-guards` | **actif** — seul runner du dépôt ; Windows ; tool-cache seedé (a2), ré-enregistrement sans UAC, jobs réels consommés | +| `myia-po-2025-fast-guards` | en préparation (aucun runner enregistré) | +| `myia-po-2026-fast-guards` | en préparation (aucun runner enregistré ; profil vérifié dans le registre) | +| `coursia-linux` (conteneur) | **inexistant** — label ciblé par `linux-self-hosted-tests.yml`, aucun runner ne le porte. Image non construite | + +La colonne est datée d'une **mesure**, pas d'une intention : le tableau précédent portait « actif » et « en préparation » sans dire ce qui avait été compté, ce qui a laissé lire une conception comme un déploiement. Le réglage GitHub « Require approval for all outside collaborators » complète la garde YAML ; il ne la remplace jamais (non exposé par l'API `/actions/permissions` — capture à faire côté admin, UI Settings → Actions). L'activation finale reste un geste explicite du user ou du coordinateur, après validation des tranches précédentes. diff --git a/scripts/ci/docker/linux-runner/supervise.sh b/scripts/ci/docker/linux-runner/supervise.sh new file mode 100644 index 0000000000..3982471f17 --- /dev/null +++ b/scripts/ci/docker/linux-runner/supervise.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Superviseur de conteneurs runner ephemeres (mission #13378, finalisation +# ai-01 2026-09-01 sur demande user « debrayer le CI sur po-2024 »). +# +# POURQUOI CE SCRIPT EXISTE +# ------------------------- +# Le census #13378 avait nomme le vrai facteur limitant du volet self-hosted : +# le runner est --ephemeral, donc il traite AU PLUS UN JOB puis se desenregistre. +# Cote Windows, chaque job supplementaire exigeait un re-register porte par une +# tache planifiee -- c'est ce qui rendait tout elargissement contre-productif. +# +# Le conteneur dissout ce verrou : la re-inscription n'est plus une tache a +# orchestrer, c'est un `docker run` de plus. Ce script est la boucle qui en +# tire la consequence -- un slot = une boucle, N slots = N jobs concurrents. +# +# CONTRAINTE QUI PRIME SUR TOUT : l'hote est AUSSI une workstation GPU et +# interactive. Les caps par conteneur et le N par defaut sont volontairement +# bas. Si l'empreinte gene la machine, baisser N ou arreter -- l'hote prime, +# et cette clause survit a toute decision d'elargissement (docs/ci/self-hosted-runners.md). +# +# USAGE +# ./supervise.sh start [N] # N slots (defaut 2) +# ./supervise.sh stop # arret gracieux : pas de nouveau conteneur +# ./supervise.sh status +# +# PREREQUIS : docker, gh authentifie avec droit admin sur le depot (le fetch +# du registration token l'exige). Le token n'est JAMAIS passe en argv -- +# uniquement par -e, comme entrypoint.sh et manage_self_hosted_runner.py. +set -uo pipefail + +REPO="${COURSIA_RUNNER_REPO:-jsboige/CoursIA}" +IMAGE="${COURSIA_RUNNER_IMAGE:-coursia-linux-runner:2.336.0}" +LABELS="${COURSIA_RUNNER_LABELS:-self-hosted,coursia-ephemeral,coursia-linux}" +NAME_PREFIX="${COURSIA_RUNNER_NAME_PREFIX:-myia-po-2024-linux-docker}" +STATE_DIR="${COURSIA_RUNNER_STATE_DIR:-$HOME/.coursia-runner}" +STOP_FILE="$STATE_DIR/stop" + +# Caps par conteneur. Volontairement conservateurs : l'hote prime sur la CI. +CPUS="${COURSIA_RUNNER_CPUS:-3}" +MEMORY="${COURSIA_RUNNER_MEMORY:-4g}" +PIDS="${COURSIA_RUNNER_PIDS:-384}" + +mkdir -p "$STATE_DIR" + +die() { echo "ERREUR: $*" >&2; exit 1; } + +fetch_token() { + # Le registration token vaut 1 h et est jetable : un par demarrage de + # conteneur. C'est la raison pour laquelle la boucle vit sur l'HOTE et non + # dans l'image -- `gh` et ses credentials ne descendent jamais dans le + # conteneur. + gh api --method POST "repos/$REPO/actions/runners/registration-token" --jq .token 2>/dev/null +} + +slot_loop() { + local slot="$1" + local name="${NAME_PREFIX}-${slot}" + echo "[slot $slot] demarrage, nom runner=$name" + while [ ! -f "$STOP_FILE" ]; do + local token + token="$(fetch_token)" + if [ -z "$token" ]; then + echo "[slot $slot] token indisponible (droit admin gh ?) -- nouvelle tentative dans 60 s" >&2 + sleep 60 + continue + fi + # --rm : le conteneur disparait avec le job. --ephemeral (dans l'entrypoint) + # desenregistre le runner cote GitHub. Un cycle = un job, proprement. + docker run --rm \ + --name "$name" \ + --cpus="$CPUS" --memory="$MEMORY" --pids-limit="$PIDS" \ + --security-opt=no-new-privileges \ + -e ACTIONS_RUNNER_INPUT_TOKEN="$token" \ + -e ACTIONS_RUNNER_INPUT_URL="https://github.com/$REPO" \ + -e ACTIONS_RUNNER_INPUT_NAME="$name" \ + -e ACTIONS_RUNNER_INPUT_LABELS="$LABELS" \ + "$IMAGE" >>"$STATE_DIR/$name.log" 2>&1 + local rc=$? + echo "[slot $slot] conteneur termine (rc=$rc)" + # Anti-emballement : si le conteneur meurt immediatement et en boucle + # (image absente, token refuse), on ne martele ni docker ni l'API. + [ "$rc" -ne 0 ] && sleep 15 || sleep 2 + done + echo "[slot $slot] arret demande, boucle terminee" +} + +cmd_start() { + local n="${1:-2}" + command -v docker >/dev/null || die "docker introuvable" + command -v gh >/dev/null || die "gh introuvable" + docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "image $IMAGE absente -- construire d'abord : + docker build -t $IMAGE scripts/ci/docker/linux-runner/" + rm -f "$STOP_FILE" + echo "demarrage de $n slot(s) ; caps par conteneur : cpus=$CPUS memory=$MEMORY pids=$PIDS" + for i in $(seq 1 "$n"); do + slot_loop "$i" & + echo "$!" >> "$STATE_DIR/pids" + done + echo "slots lances. Arret gracieux : $0 stop" + wait +} + +cmd_stop() { + # Arret GRACIEUX : on pose le sentinel, les boucles ne relancent plus de + # conteneur. Le job en cours va a son terme -- on ne tue pas un job qui + # tourne, il rendrait un rouge qui ne veut rien dire. + touch "$STOP_FILE" + echo "sentinel pose : aucun nouveau conteneur ne sera lance." + echo "Les jobs en cours vont a leur terme. Pour couper net (deconseille) :" + echo " docker ps --filter name=$NAME_PREFIX -q | xargs -r docker kill" +} + +cmd_status() { + echo "== conteneurs runner en cours ==" + docker ps --filter "name=$NAME_PREFIX" --format ' {{.Names}} {{.Status}} {{.RunningFor}}' 2>/dev/null || true + echo "== runners enregistres cote GitHub ==" + gh api "repos/$REPO/actions/runners" \ + --jq '.runners[]|" \(.name) [\(.status)] busy=\(.busy) labels=\([.labels[].name]|join(","))"' 2>/dev/null \ + || echo " (droit admin requis pour lire l'inventaire)" + [ -f "$STOP_FILE" ] && echo "== sentinel STOP pose : les boucles ne relancent plus ==" +} + +case "${1:-}" in + start) shift; cmd_start "${1:-2}" ;; + stop) cmd_stop ;; + status) cmd_status ;; + *) echo "usage: $0 {start [N]|stop|status}"; exit 2 ;; +esac