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
10 changes: 10 additions & 0 deletions scripts/ci/check_companion_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,22 @@
# le lanceur a plafond memoire Natif (Job Object Windows, `run_guarded.py`)
# dont le plafond n'a pas d'equivalent POSIX : un compagnon .sh serait une
# imitation sans lecture derriere.
#
# `persist/po-2027/install-keepalive-task.ps1` releve de la meme classe, et
# c'est mesure : il installe une tache planifiee Windows qui re-invoque le
# pont WSL toutes les N minutes, parce que le porteur du runner vit
# exactement aussi longtemps que la distro. Le compagnon .sh qui viendrait a
# cote est celui qui tourne DANS la distro (`coursia-runner-start.sh`) --
# c'est-a-dire precisement le processus que le `wsl --shutdown` tue, donc
# celui qui ne peut pas se re-armer lui-meme. Un `.sh` compagnon ici serait
# la copie d'un geste que seul l'hote Windows peut executer.
EXEMPT_FILES = {
"docker-configurations/scripts/setup-win-acme-renewal.ps1",
"docker-configurations/services/tts-multi/setup-iis-reverse-proxy.ps1",
"scripts/genai-stack/Configure-IISAuthentication.ps1",
"scripts/ci/docker/linux-runner/persist/hold-runner.ps1",
"scripts/ci/docker/linux-runner/persist/host-distress-probe.ps1",
"scripts/ci/docker/linux-runner/persist/po-2027/install-keepalive-task.ps1",
"MyIA.AI.Notebooks/QuantConnect/ML-Training-Pipeline/scripts/launchers/launch_capped.ps1",
}

Expand Down
91 changes: 91 additions & 0 deletions scripts/ci/docker/linux-runner/persist/po-2027/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Leg coursia-runner de po-2027 (Linux, ephemere)

Slots Docker Linux pour le pool `coursia-linux`, en mode **ephemere** : un job par
enregistrement, puis le conteneur se retire et le superviseur en demarre un neuf.
La machine reste un poste de travail — les caps sont dimensionnes pour que l'hote
prime.

## Manifeste

| Fichier | Role |
|---|---|
| `coursia-runner-start.sh` | Enveloppe du legs : budget CPU/memoire, jeton, `DOCKER_HOST`, gardes, puis `exec supervise.sh` |
| `coursia-ci.slice` | Slice cgroup qui borne l'ensemble (memoire, swap, cpu) |
| `coursia-runner.service` | Unite systemd du superviseur, rattachee a la slice |
| `coursia-socket.conf` | Drop-in de `docker.service` (vide les references Requires/After a docker.socket, epingle l'ExecStart sur /var/run/docker-ce.sock) |
| `docker.service` | Override complet de l'unite Docker (necessaire sur ce systemd) |
| `install-keepalive-task.ps1` | Tache Windows qui re-arme le porteur toutes les 5 min (self-heal) |

Le pont Windows `persist/launch-runner.sh` et le porteur `hold-runner.ps1` sont
communs a toutes les machines ; seul `install-keepalive-task.ps1` est propre a ce
poste.

## Pourquoi un porteur, et pourquoi il ne suffit pas

WSL reape une distro des que le dernier client sort, **meme** avec systemd en
PID 1, le service actif et les slots online (mesure fondatrice du 2026-09-02,
consignee dans `persist/hold-runner.ps1`). Un reveil one-shot ne tient donc rien :
le porteur est un `wsl.exe` detache qui demarre le service puis dort.

Mesure du 2026-10-24 (cette machine) : ce porteur vit exactement aussi longtemps
que la distro. Un `wsl --shutdown` — ce que fait Docker Desktop a chaque
redemarrage — les tue ensemble.

| Evenement | Heure (CEST) | Effet mesure |
|---|---|---|
| Porteur spawn (pid 38780) | 23:32:22 | slots online |
| `wsl --shutdown` (Docker Desktop) | 23:54:02 | distro et porteur morts ; 4 slots disparus de GitHub |
| Boot suivant, autostart systemd | 23:54:12 | `docker.service` + `coursia-runner.service` actifs |
| Slots de nouveau online | ~23:54:2x | 4/4, sans intervention |

Le redemarrage se rétablit donc seul **cote runner** ; ce qui manquait est le
porteur. Sans lui, la distro est a nouveau reapee des que le dernier client
sort, et les slots disparaissent en silence — le seul symptome est leur absence.

## Installation

1. Fichiers systeme (sous WSL root) : `coursia-ci.slice`, `coursia-runner.service`,
`coursia-socket.conf`, `docker.service` aux emplacements systemd.
2. Pont et porteur Windows : `persist/launch-runner.sh` +
`%USERPROFILE%\.coursia-runner\hold-myia-po-2027.ps1`.
3. Tache logon (creee en eleve, une seule fois) : action
`C:\Program Files\Git\bin\bash.exe -lc "<depot>/persist/launch-runner.sh 4"`.
4. Tache de rattrapage, **non elevee** :

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File install-keepalive-task.ps1 # dry-run
powershell -NoProfile -ExecutionPolicy Bypass -File install-keepalive-task.ps1 -Apply # ecrit
```

L'appel est idempotent : porteur vivant, `hold-myia-po-2027.ps1` se contente d'un
`systemctl start` one-shot.

## Retrait

```powershell
schtasks /Delete /TN "Coursia-LinuxRunners-Keep" /F # tache de rattrapage
schtasks /Delete /TN "Coursia-LinuxRunners" /F # tache logon (en eleve)
```

Puis `supervise.sh stop` **avant** toute extinction : l'arret gracieux laisse le
job en vol se terminer, une extinction brutale le tue.

## Limite connue, a ne pas maquiller

Un `wsl --shutdown` tue les jobs en vol, et GitHub laisse alors le job
`in_progress` jusqu'a detection de la perte du runner. Cas mesure le
2026-10-24 : run `40129334303`, job `120977484412` sur
`myia-po-2027-linux-docker-4`, demarre 21:53:45Z, fige a l'etape 5 a 21:53:53Z,
soit 9 s avant le shutdown de 21:54:02Z (journal evenementiel Windows du
2026-10-24 : NIC de la VM demonte a 21:54:03Z, recree au boot suivant a
21:54:12Z ; Docker Desktop redemarre a 21:54:19Z). Le leg ne peut pas s'en proteger : la
cause est externe (redemarrage de Docker Desktop). Ce qu'il peut faire, et que
la tache de rattrapage fait, est de revenir en ligne sans intervention.

## Deregistration propre

Le mode ephemere se desinscrit seul : apres chaque job, le conteneur ecrit
`Removed .credentials` / `Removed .runner` et rend son jeton d'enregistrement.
Un `supervise.sh stop` arrete les slots sans laisser d'enregistrement orphelin ;
le registre GitHub oscille donc en fonctionnement normal (une entree par slot
present a l'instant de la lecture).
49 changes: 49 additions & 0 deletions scripts/ci/docker/linux-runner/persist/po-2027/coursia-ci.slice
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Budget AGREGE des conteneurs runner CI -- myia-po-2027 (VM WSL de 32 Go, 20 vCPU).
# Adapte le 2026-10-24 depuis persist/po-2028/coursia-ci.slice (meme distro WSL,
# meme moteur docker-ce epingle sur /var/run/docker-ce.sock). Doctrine inchangee :
# budget userspace = somme des caps ; mur kernel au-dessus du budget, sous la VM ;
# AUCUN SWAP pour la CI (MemorySwapMax=0 : un job qui deborde meurt localement,
# exit 137 retryable, au lieu de swapper la VM et de faire tomber DriveFS puis la
# coordination de flotte -- chaine causale ai-01 07/09 et po-2024 08/10, 104 GiB
# de swap ecrits en 11 h).
#
# ARITHMETIQUE (2026-10-24, depart a N=4) :
# 4 slots docker x 4096 Mo = 16 384 Mo de caps (classe Scripts Tests, pic
# mesure 3,5 Gio + agent + marge ~15 %, #16643 -- cap 4g, pids 512)
# budget userspace COURSIA_RUNNER_BUDGET_GB = 16 (= somme des caps)
# MemoryHigh = 16G (pression/reclaim AVANT la somme des caps)
# MemoryMax = 18G (filet, 2 Go au-dessus du budget, 14 Go sous la VM)
# -> 13 Go de la VM restent aux lanes CoursIA interactives et a Docker
# Desktop ; l'hote Windows (63,6 Go) garde >= 45 Go, la VM etant plafonnee
# a 32 Go par .wslconfig.
#
# I/O : valeurs REPRISES de la mesure ai-01 (2026-09-07, 3 conteneurs :
# 221 Mo/s cumules sous plafond contre 16,9 Go/s sans, facteur 76). Role :
# interdire qu'un thrash PASSE L'ECHELLE (~2 % d'un NVMe), pas reguler un
# debit normal.
#
# CPU : pas de CPUQuota ici (doctrine po-2024/po-2028) -- l'axe CPU est garde
# par COURSIA_RUNNER_CPU_BUDGET=10 dans le wrapper (borne = moitie des 20 vCPU,
# garde 1 de l'addendum ai-01 du 2026-10-24 ; caps effectifs 4 x 2 = 8 vCPU,
# soit 40 %), refus au demarrage au depassement.
#
# ORDRE : cette slice se deploye AVANT les conteneurs ; assert_slice
# (supervise.sh) REFUSE le demarrage si elle est absente ou sans io.max
# (COURSIA_RUNNER_REQUIRE_CGROUP_BUDGET=1 dans le wrapper po-2027).
[Unit]
Description=CoursIA CI runner containers -- aggregate budget (all families, po-2027)
Documentation=https://github.com/jsboige/CoursIA/blob/main/docs/ci/self-hosted-runners.md
Before=slices.target

[Slice]
CPUAccounting=yes

IOAccounting=yes
IOWeight=50
IOWriteBandwidthMax=/var/lib/docker 209715200
IOReadBandwidthMax=/var/lib/docker 419430400

MemoryAccounting=yes
MemoryHigh=16G
MemoryMax=18G
MemorySwapMax=0
139 changes: 139 additions & 0 deletions scripts/ci/docker/linux-runner/persist/po-2027/coursia-runner-start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Wrapper de demarrage du superviseur de runners Linux CoursIA sous systemd.
# Deploiement myia-po-2027 (2026-10-24) : copie de reference -- l'original vit
# dans la distro Ubuntu de l'hote, sous /usr/local/bin/coursia-runner-start.sh.
# Adapte de persist/coursia-runner-start.sh (po-2024) et de
# persist/po-2028/coursia-runner-start.sh, dont la structure de jetons est
# reprise : le jeton n'est ni stocke ni passe en argv, il est relu a CHAQUE
# invocation.
#
# DELTAS po-2027, mesures le 2026-10-24 :
# - distro Ubuntu 26.04 « resolute », systemd PID 1, 20 vCPU / 31 Go vus ;
# - `GH_RUNNERS_ADMIN_TOKEN` est ABSENT du master.env de cette machine : la
# voie de repli de po-2028 (gh.exe + APPDATA Windows) est donc la voie
# nominale ici, le master.env restant prioritaire s'il est renseigne un
# jour (une seule lecture, deux sources ordonnees) ;
# - `gh` natif installe dans la distro (2.103.2) parce que supervise.sh
# l'appelle directement (`command -v gh`) et que le
# conteneur ne peut pas executer un .exe Windows ;
# - le daemon est docker-ce 29.9.2 epingle sur /var/run/docker-ce.sock ; le
# drop-in de flotte ne suffisait pas sur ce systemd (Requires/After vers
# docker.socket non vidables), d'ou l'override d'unite
# persist/po-2027/docker.service, pose avec persist/po-2027/coursia-socket.conf.
set -euo pipefail

# Racine du depot. Le defaut suit le chemin REEL de cette machine : la migration
# du 2026-09-17 a deplace `C:\dev\CoursIA` -> `D:\Dev\CoursIA` (le `dev` est en
# minuscules ici, et /mnt/d est insensible a la casse).
REPO_DIR="${COURSIA_REPO_DIR:-/mnt/d/dev/CoursIA}"

# --- JETON (jamais en argv, jamais stocke dans la distro) -------------------
# Ordre : master.env d'abord (doctrine de flotte, CRLF interdit -> tr -d '\r'),
# puis le CLI Windows, dont le jeton vit dans le trousseau Windows. Le `gh.exe`
# lance depuis la distro n'herite pas de APPDATA : sans cette variable il rend
# une chaine vide (mesure du 2026-10-24), ce qui produisait un FATAL opaque.
TOKEN_FILE="${COURSIA_MASTER_ENV:-$REPO_DIR/.secrets/master.env}"
TOKEN=""
if [ -r "$TOKEN_FILE" ]; then
TOKEN="$(sed -n 's/^GH_RUNNERS_ADMIN_TOKEN=//p' "$TOKEN_FILE" | tr -d '\r')"
fi
if [ -z "$TOKEN" ]; then
GH_WIN="/mnt/c/Program Files/GitHub CLI/gh.exe"
[ -x "$GH_WIN" ] || GH_WIN="$(command -v gh.exe || true)"
if [ -n "${GH_WIN:-}" ] && [ -x "$GH_WIN" ]; then
TOKEN="$(APPDATA='C:\Users\Jesse\AppData\Roaming' "$GH_WIN" auth token 2>/dev/null | tr -d '\r\n')"
fi
fi
if [ -z "$TOKEN" ]; then
echo "FATAL: aucun jeton GitHub -- GH_RUNNERS_ADMIN_TOKEN absent de $TOKEN_FILE ET gh.exe muet (auth Windows). Reparer l'un des deux avant de relancer le service." >&2
exit 1
fi

export DOCKER_HOST="unix:///var/run/docker-ce.sock"
export GH_TOKEN="$TOKEN"
export COURSIA_RUNNER_REPO="jsboige/CoursIA"
export COURSIA_RUNNER_NAME_PREFIX="myia-po-2027-linux-docker"
export COURSIA_RUNNER_STATE_DIR="/var/lib/coursia-runner"
export COURSIA_RUNNER_TOOLCACHE_VOLUME="coursia-runner-toolcache"

# --- DIMENSIONNEMENT (l'hote prime : c'est aussi un poste GPU interactif) ----
# N = 4 slots, caps 2 vCPU / 4 Go / 512 pids. Pourquoi ces caps et pas les
# 1536 Mo du defaut du superviseur : la classe « Scripts Tests » culmine a
# 3,5 Gio (mesure po-2028 du 2026-10-20, #16643) ; a 1536 Mo elle meurt sur
# KeyError xdist, c'est-a-dire sur des workers tues par le cap, pas sur un test
# rouge. On reduit donc le NOMBRE de slots, jamais le cap unitaire.
#
# ARITHMETIQUE DECLAREE (regle #19802 : le budget est la somme des caps) :
# 4 slots x 4 Go = 16 384 Mo -> COURSIA_RUNNER_BUDGET_GB=16
# 4 slots x 2 vCPU = 8 vCPU sur 20 (40 %) -> COURSIA_RUNNER_CPU_BUDGET=10
# slice : MemoryHigh=16G, MemoryMax=18G, MemorySwapMax=0 (mur kernel)
# La VM WSL est plafonnee a 32 Go (.wslconfig) et l'hote a 63,6 Go : 18 Go de
# CI laissent 13 Go dans la VM aux lanes interactives, et >= 45 Go a Windows.
#
# Pourquoi 2 vCPU et un budget de 10 : la garde d'addendum ai-01 du 2026-10-24
# (c1843, garde 1) definit COURSIA_RUNNER_CPU_BUDGET comme la MOITIE des coeurs
# logiques de la VM -- 20 nproc -> 10 -- et « l'hote doit primer ». Un premier
# demarrage a 4 x 3 = 12 vCPU (60 %) depassait cette borne : le cap unitaire a
# ete ramene a 2, en gardant les 4 slots (plus de jobs simultanes, moins de
# coeurs chacun ; le pool voisin n'a pas de mal a absorber la latence).
# Aucune jambe coursia-waiter n'est installee sur cette machine (dispatch
# ai-01 du 2026-10-24) : la famine se traite par des slots d'execution, pas
# par des places dans la file.
export COURSIA_RUNNER_BUDGET_GB=16
export COURSIA_RUNNER_CPU_BUDGET=10
export COURSIA_RUNNER_CPUS=2
export COURSIA_RUNNER_MEMORY=4g
export COURSIA_RUNNER_PIDS=512

# Le mur kernel des conteneurs : la meme slice que daemon.json declare en
# cgroup-parent. assert_slice REFUSE le demarrage si elle est absente ou sans
# io.max -- d'ou REQUIRE_CGROUP_BUDGET=1 (fail-closed, jamais un pool non
# borne en silence).
export COURSIA_RUNNER_CGROUP_PARENT="coursia-ci.slice"
export COURSIA_RUNNER_REQUIRE_CGROUP_BUDGET=1

# I/O : valeurs reprises de la mesure ai-01 (2026-09-07, 221 Mo/s cumules sous
# plafond contre 16,9 Go/s sans). Role : empecher qu'un thrash passe l'echelle,
# pas reguler un debit normal.
export COURSIA_RUNNER_DEVICE_WRITE_BPS=41943040
export COURSIA_RUNNER_DEVICE_READ_BPS=83886080

# PATH explicite : l'unite systemd ne charge pas de profil de shell, et le CLI
# GitHub installe par apt vit sous /usr/bin.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# #15095 : echec immediat si le demon du socket epingle ne repond pas --
# AVANT tout demarrage de slot et tout fetch de registration token (gh).
# Sans cette garde, un daemon arrete + Restart=always = le superviseur
# martelait docker/gh indefiniment (incident 07/09 : 2 gels machine ai-01,
# ecriture ext4.vhdx 94-96 Mo/s). Le superviseur porte la meme garde en
# profondeur (assert_docker_daemon) -- celle-ci rend l'echec visible au
# niveau systemd des l'ExecStart.
if ! docker info >/dev/null 2>&1; then
echo "FATAL: demon Docker indisponible sur DOCKER_HOST=$DOCKER_HOST (docker info echoue, #15095) -- reparer le daemon avant de relancer le service." >&2
exit 1
fi

SUPERVISE="$REPO_DIR/scripts/ci/docker/linux-runner/supervise.sh"
mkdir -p "$COURSIA_RUNNER_STATE_DIR"

# --- PURGE SENTINELLE PERIMEE (#15163) -----------------------------
# supervise.sh pose "$STATE_DIR/stop" a l'arret gracieux et REFUSE de
# demarrer tant qu'elle est la. La sentinelle est un FICHIER : elle survit
# au reboot. Un arret gracieux suivi d'un redemarrage machine laissait donc
# l'unite echouer, et le pool restait a ZERO jusqu'a intervention humaine.
#
# Le predicat est PAR-JAMBE : un `supervise.sh (start|waiters)` repondrait
# « un superviseur QUELCONQUE vit-il ? » et chaque jambe refuserait de purger
# SA sentinelle perimee tant que l'autre tourne.
if [ "${1:-start}" = "start" ] && [ -e "$COURSIA_RUNNER_STATE_DIR/stop" ]; then
if pgrep -f 'supervise\.sh start' >/dev/null 2>&1; then
echo "sentinelle presente ET superviseur vivant -- arret en cours, on n'interfere pas" >&2
exit 1
fi
echo "sentinelle perimee (aucun superviseur vivant) -- purge avant demarrage" >&2
rm -f "$COURSIA_RUNNER_STATE_DIR/stop"
fi
# ---------------------------------------------------------------------------

exec "$SUPERVISE" "$@"
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Unite systemd de persistance du superviseur de runners Linux CoursIA.
# Deploiement myia-po-2027 (2026-10-24) -- copie de reference ; l'original vit
# dans la distro Ubuntu sous /etc/systemd/system/coursia-runner.service.
# Adaptee de persist/po-2028/coursia-runner.service (structure et gardes
# conformes #15095 fail-closed, #15091 IOWeight, #15163 sentinel) ; seul le
# dimensionnement change -- start 4 (composition et historique dans
# coursia-runner-start.sh, deltas po-2027, et dans coursia-ci.slice).
[Unit]
Description=CoursIA Linux runners supervisor (docker slots, po-2027)
# #15095 : Requires+BindsTo fail-closed -- docker tombe ou echoue au boot =>
# le superviseur est ARRETE (propagation), pas laisse a marteler docker/gh.
# Retour du daemon = geste operateur explicite (systemctl start coursia-runner)
# -- l'hote prime sur la CI.
Requires=docker.service
BindsTo=docker.service
After=docker.service
# Borne de rafale : 5 tentatives / 300 s, puis failed et SORTIE de boucle.
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
ExecStart=/usr/local/bin/coursia-runner-start.sh start 4
ExecStop=/usr/local/bin/coursia-runner-start.sh stop
Restart=always
RestartSec=30
Nice=10
# ExecStop est gracieux (sentinel supervise.sh) : un job en vol va a son terme.
TimeoutStopSec=900
# #15091 : IOWeight fait ceder le parc sous contention. Le plafond DUR vit
# dans la slice (les conteneurs sont les neveux de ce service) :
# coursia-ci.slice po-2027 porte IOWrite/ReadBandwidthMax + murs memoire.
IOAccounting=yes
IOWeight=50

[Install]
WantedBy=multi-user.target
19 changes: 19 additions & 0 deletions scripts/ci/docker/linux-runner/persist/po-2027/coursia-socket.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Po-2027 -- epingle le daemon docker-ce sur /var/run/docker-ce.sock.
# Recette de flotte : docs/ci/self-hosted-runners.md, "Persistance du superviseur".
# Le socket PAR DEFAUT de cette machine revient au proxy Docker Desktop (engine qui
# porte claudish-proxy) : le conteneur de CI ne doit jamais partager l'engine
# interactif.
#
# PIEGE mesure sur po-2028 (2026-10-20) puis ici (2026-10-24) : l'unite stock
# docker.service porte Requires=docker.socket ET After=...docker.socket. Vider
# Requires= ne suffit pas a dockerd-ce 29.9.2 : docker.socket masque, systemctl
# refuse le demarrage sur la reference d'ordre. Les deux sont donc vidEES.
[Unit]
Requires=
Requires=sysinit.target system.slice
After=
After=network-online.target firewalld.service containerd.service time-set.target basic.target nss-lookup.target system.slice systemd-journald.socket sysinit.target

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H unix:///var/run/docker-ce.sock --containerd=/run/containerd/containerd.sock
Loading
Loading