Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/linux-self-hosted-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 38 additions & 8 deletions docs/ci/self-hosted-runners.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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.
129 changes: 129 additions & 0 deletions scripts/ci/docker/linux-runner/supervise.sh
Original file line number Diff line number Diff line change
@@ -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
Loading