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
33 changes: 33 additions & 0 deletions docs/ci/self-hosted-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,39 @@ Deux pièges font échouer un cache écrit « au feeling » :

**Résiduel honnête** : A2/A3 (contrôles positif et négatif sur un **log de job** réel) ne sont pas satisfaits par cette tranche. Ils exigent une image **reconstruite et redéployée**, puis un job réel : la preuve qu'on peut apporter sans déploiement s'arrête au contenu de l'image, et c'est ce qui est mesuré ci-dessus.

### Mode persistent — le conteneur qui retire le maillon superviseur (#14329)

Le superviseur hôte n'existe que parce que les runners sont `--ephemeral` : un runner éphémère traite **au plus un job** puis se désenregistre, donc un processus hôte doit reminter un token et relancer un conteneur à chaque job — et ce processus hôte est exactement le maillon qui meurt au logoff (mesure du reboot 2026-09-02 : flotte retombée à 1 runner, tous les `PR gate` gelés en STARVED). L'idée user : « un conteneur en autorestart qui fait le polling tout seul ».

**Le design livré dans l'image** (`RUNNER_MODE=persistent`, `--ephemeral` reste le défaut — rétro-compatible) :

- **Volume de config par slot** monté sur `/opt/runner` : il porte le layout complet du runner (`.runner` + `.credentials` + binaires). L'image extrait le tarball vers `/opt/runner-dist` (source vierge) **et** copie vers `/opt/runner` ; si le volume est créé vide, l'entrypoint restaure les binaires depuis `runner-dist` au boot — monter un volume sur `/opt/runner` ne masque donc jamais les binaires.
- **Enregistrement une seule fois** : `token`/`url`/`name`/`labels` ne sont exigés que si `.runner` est absent (premier boot). Aux restarts suivants, l'entrypoint détecte `.runner`, journalise « reprise sans ré-enregistrement » et lance `run.sh` directement — **aucune variable requise**, le token (valable 1 h) ne sert plus jamais.
- **Pas de teardown** : le `trap 'config.sh remove'` du mode éphémère est absent — désenregistrer au EXIT tuerait la propriété même du mode. `--replace` reste posé à l'enregistrement : un slot recréé remplace son entrée offline.

Lancement type (slot N) :

```
docker run -d --restart unless-stopped \
-v coursia-runner-cfg-N:/opt/runner \
-v coursia-work-N:/home/runner/_work \
-e RUNNER_MODE=persistent \
-e ACTIONS_RUNNER_INPUT_TOKEN=... -e ACTIONS_RUNNER_INPUT_URL=https://github.com/jsboige/CoursIA \
-e ACTIONS_RUNNER_INPUT_NAME=myia-po-2024-linux-docker-N \
-e ACTIONS_RUNNER_INPUT_LABELS=self-hosted,coursia-linux \
coursia-linux-runner
```

Docker relance le conteneur à chaque démarrage du daemon (donc de la distro) ; le volume `_work` de #14288 se **combine** avec celui de config, il ne s'y substitue pas.

**Ce que la non-éphéméralité coûte — écrit, pas supposé** :

1. **État persistant entre jobs.** C'est précisément ce que `--ephemeral` achetait (#13378) : workspace, tool-cache et processus résiduels survivent d'un job au suivant. Le troc est accepté parce que la contrainte « le code des forks étudiants ne touche jamais le self-hosted » tient **par ailleurs** : aucun `pull_request_target` auto-hébergé, garde universelle fork/payload (`github.event.pull_request.head.repo.full_name == github.repository`, tranche 4), et « Require approval for all outside collaborators » côté dépôt. Retirer la persistance avant tout trigger `pull_request` reste la règle.
2. **Sémantique des hooks entrypoint.** En éphémère, les blocs de désarmement (sparse-checkout résiduel, refs dangling) et `work_cache_health` s'exécutent **à chaque job** (conteneur `--rm` par job). En persistent, ils s'exécutent **au boot du conteneur** seulement : le runner enchaîne les jobs sans relancer l'entrypoint. Le nettoyage inter-jobs repose alors sur le `clean` par défaut du checkout ; le hook de boot reste en première ligne à chaque restart Docker. Ce n'est pas un renforcement : c'est une couverture **moins fréquente**, assumée.
3. **Un slot offline consomme son inscription.** Un runner non-éphémère arrêté reste enregistré « idle/offline » sur GitHub jusqu'à son retour — contrairement à l'éphémère qui se désenregistre seul. Sans incident tant que le conteneur revient ; c'est le compteur GitHub à lire après un long arrêt.

**Validation au déploiement (po-2024 uniquement, jamais ai-01)** : la preuve du mode est un `systemctl restart docker` qui voit les slots revenir **sans intervention** — geste destructif réservé à la machine qui ne porte ni vLLM, ni Qdrant, ni ComfyUI. La mesure avant/après du temps de checkout se prend au même moment (volumes `_work` + config combinés).

## Tranches suivantes, activation partielle

La préparation complète reste découpée :
Expand Down
19 changes: 14 additions & 5 deletions scripts/ci/docker/linux-runner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,25 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*

# Tarball runner epingle par SHA-256 (meme discipline que les profils
# self_hosted_runner_profiles.json).
# self_hosted_runner_profiles.json). Extraction vers /opt/runner-dist (source
# vierge), puis copie vers /opt/runner : en mode persistent (#14329), un volume
# de config monte sur /opt/runner masque les binaires -- l'entrypoint les
# restaure depuis runner-dist au boot. En ephemeral, le layout est inchangé.
ADD https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz /tmp/runner.tar.gz
RUN echo "${RUNNER_SHA256} /tmp/runner.tar.gz" | sha256sum -c - \
&& mkdir -p /opt/runner \
&& tar xzf /tmp/runner.tar.gz -C /opt/runner \
&& mkdir -p /opt/runner-dist /opt/runner \
&& tar xzf /tmp/runner.tar.gz -C /opt/runner-dist \
&& cp -a /opt/runner-dist/. /opt/runner/ \
&& rm /tmp/runner.tar.gz

# /opt/hostedtoolcache : point de montage du volume persistant (supervise.sh).
# Cree ET possede par runner ici pour que le volume nomme herite cette
# propriete a sa premiere creation -- sinon il serait root:root et les
# actions setup-* echoueraient en EACCES sous l'utilisateur non-root.
# Idem /opt/runner-dist : restaure par l'entrypoint dans un volume runner:runner.
RUN useradd -m -u 1001 runner \
&& mkdir -p /home/runner/_work /opt/hostedtoolcache \
&& chown -R runner:runner /opt/runner /home/runner /opt/hostedtoolcache
&& chown -R runner:runner /opt/runner /opt/runner-dist /home/runner /opt/hostedtoolcache

# gh CLI : requis par les guards metadata (check_pr_perimeter POST sa label
# via gh ; sans gh, les organs sous `2>/dev/null || true` rendent exit 0 SANS
Expand Down Expand Up @@ -121,8 +126,12 @@ WORKDIR /opt/runner

# work_cache_health.sh : moitie executante de #15105, sourcee par l'entrypoint
# au demarrage de chaque conteneur (controle du cache _work persistant).
# Copie dans runner-dist AUSSI : un volume persistent restaure tout le layout
# depuis la source, entrypoint inclus (#14329).
COPY --chown=runner:runner work_cache_health.sh /opt/runner/work_cache_health.sh
COPY --chown=runner:runner entrypoint.sh /opt/runner/entrypoint.sh
RUN chmod +x /opt/runner/entrypoint.sh
COPY --chown=runner:runner work_cache_health.sh /opt/runner-dist/work_cache_health.sh
COPY --chown=runner:runner entrypoint.sh /opt/runner-dist/entrypoint.sh
RUN chmod +x /opt/runner/entrypoint.sh /opt/runner-dist/entrypoint.sh

ENTRYPOINT ["/opt/runner/entrypoint.sh"]
72 changes: 65 additions & 7 deletions scripts/ci/docker/linux-runner/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,43 @@
# ACTIONS_RUNNER_INPUT_URL https://github.com/jsboige/CoursIA
# ACTIONS_RUNNER_INPUT_NAME myia-po-2024-linux-docker
# ACTIONS_RUNNER_INPUT_LABELS self-hosted,coursia-ephemeral,coursia-linux
# Sauf en mode persistent deja enregistre (RUNNER_MODE=persistent avec un
# volume de config qui porte .runner/.credentials : aucune de ces variables
# n'est requise au restart -- #14329).
set -euo pipefail

: "${ACTIONS_RUNNER_INPUT_TOKEN:?RUNNER token manquant}"
: "${ACTIONS_RUNNER_INPUT_URL:?RUNNER url manquante}"
: "${ACTIONS_RUNNER_INPUT_NAME:?RUNNER name manquant}"
: "${ACTIONS_RUNNER_INPUT_LABELS:?RUNNER labels manquants}"
# --- Mode persistent : enregistrement unique, restart sans token (#14329) ----
# TEST-ENTRYPOINT-PERSISTENT-START
# RUNNER_HOME porte le layout runner complet (binaires + .runner + .credentials)
# et est le point de montage du volume de config PAR SLOT en mode persistent.
# RUNNER_DIST est la copie vierge de l'image : source de restauration quand le
# volume est cree vide (un volume nomme existant masque les binaires de l'image).
RUNNER_HOME="${RUNNER_HOME:-/opt/runner}"
RUNNER_DIST="${RUNNER_DIST:-/opt/runner-dist}"
RUNNER_MODE="${RUNNER_MODE:-ephemeral}"

runner_mode() { printf '%s' "$RUNNER_MODE"; }

runner_restore_dist() {
# Un volume monté vide sur RUNNER_HOME masque les binaires extraits au build :
# on les restaure depuis RUNNER_DIST. Idempotent (cp -a écrase sans risque,
# source identique a l'image epinglee).
if [ ! -x "$RUNNER_HOME/run.sh" ] && [ -x "$RUNNER_DIST/run.sh" ]; then
echo "entrypoint: $RUNNER_HOME vide -- restauration des binaires depuis $RUNNER_DIST"
cp -a "$RUNNER_DIST/." "$RUNNER_HOME/"
fi
}

runner_is_registered() { [ -f "$RUNNER_HOME/.runner" ]; }

require_registration_env() {
: "${ACTIONS_RUNNER_INPUT_TOKEN:?RUNNER token manquant}"
: "${ACTIONS_RUNNER_INPUT_URL:?RUNNER url manquante}"
: "${ACTIONS_RUNNER_INPUT_NAME:?RUNNER name manquant}"
: "${ACTIONS_RUNNER_INPUT_LABELS:?RUNNER labels manquants}"
}
# TEST-ENTRYPOINT-PERSISTENT-END

export ACTIONS_RUNNER_INPUT_EPHEMERAL=true
export ACTIONS_RUNNER_INPUT_REPLACE=true
export ACTIONS_RUNNER_INPUT_WORK=/home/runner/_work

# --- Desarmement de l'etat sparse-checkout residuel (slot poisoning) ---------
Expand Down Expand Up @@ -97,7 +125,32 @@ WCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
wch_check_workdir "$ACTIONS_RUNNER_INPUT_WORK" "${RUNNER_WORK_CACHE_PACK_THRESHOLD:-16}"
# ---------------------------------------------------------------------------

cd /opt/runner
cd "$RUNNER_HOME"

if [ "$(runner_mode)" = "persistent" ]; then
# Mode #14329 : le conteneur est --restart unless-stopped et le runner
# NON-ephemere. Enregistrement UNE fois : .runner/.credentials vivent dans
# le volume monte sur RUNNER_HOME, un restart (daemon, reboot machine) reprend
# run.sh sans token -- c'est la propriete que le mode achete. --replace couvre
# la recreeation du volume (l'ancien slot offline est remplace par le nouveau).
# Semantique des hooks _work ci-dessus en persistent : ils s'executent au BOOT
# du conteneur, pas a chaque job -- le runner enchaine les jobs sans relancer
# l'entrypoint. Le trade-off est ecrit dans docs/ci/self-hosted-runners.md.
runner_restore_dist
if runner_is_registered; then
echo "entrypoint: .runner present -- reprise sans re-enregistrement (token non requis)"
else
echo "entrypoint: mode persistent, premier enregistrement"
require_registration_env
# Pas d'ACTIONS_RUNNER_INPUT_EPHEMERAL : absent = runner non-ephemere.
export ACTIONS_RUNNER_INPUT_REPLACE=true
./config.sh --unattended --disableupdate
fi
# Pas de trap 'config.sh remove' : desenregistrer au EXIT tuerait la
# propriete meme du mode (le restart doit retrouver le slot enregistre).
exec ./run.sh
fi

# --disableupdate : le conteneur est --rm et le runner --ephemeral (un seul
# job puis mort). Un self-update n'y est donc jamais CONSERVE -- GitHub ordonne
# la mise a jour, le runner telecharge le tarball apres le job, le conteneur
Expand All @@ -118,6 +171,11 @@ cd /opt/runner
# La version du runner EST celle de l'image : elle se bumpe par un rebuild
# (ARG RUNNER_VERSION du Dockerfile), jamais a chaud. Sans ce flag, la prochaine
# exigence de version rearme exactement la meme boucle. Cf #15153, #15164.
# (Ces mesures sont ephemeral-specific ; en persistent le self-update garde
# le meme interdit d'image epinglee, la version se bumpe par rebuild.)
require_registration_env
export ACTIONS_RUNNER_INPUT_EPHEMERAL=true
export ACTIONS_RUNNER_INPUT_REPLACE=true
./config.sh --unattended --ephemeral --replace --disableupdate

# Teardown symetrique : --ephemeral desenregistre de lui-meme apres le job ;
Expand Down
146 changes: 146 additions & 0 deletions scripts/ci/docker/linux-runner/test_entrypoint_persistent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Tests du mode persistent de entrypoint.sh (#14329) : enregistrement unique,
# restart sans token, restauration des binaires depuis runner-dist.
#
# Methode (famille test_entrypoint_disarm.sh) : on extrait le bloc REEL du
# script sous test (marqueurs de section) et on l'execute contre des fixtures
# de repertoire -- pas de stub de runner, la decision se teste sur l'etat du
# filesystem. RUNNER_HOME/RUNNER_DIST sont parametrables par env justement
# pour ce test.

set -o pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_DIR="/tmp/entrypoint-persistent-test-$$"
mkdir -p "$TEST_DIR"
LOG="$TEST_DIR/test.log"
: > "$LOG"

RESULTS="$TEST_DIR/results"
: > "$RESULTS"
ok() { echo " PASS: $1"; echo "PASS $1" >> "$RESULTS"; }
ko() { echo " FAIL: $1"; echo "FAIL $1" >> "$RESULTS"; }

# Extraction du bloc reel : du marqueur START au marqueur END.
if ! sed -n '/# TEST-ENTRYPOINT-PERSISTENT-START/,/# TEST-ENTRYPOINT-PERSISTENT-END/p' \
"$SCRIPT_DIR/entrypoint.sh" > "$TEST_DIR/block.sh" || \
! grep -q "runner_is_registered" "$TEST_DIR/block.sh"; then
echo "FAIL extraction du bloc persistent de entrypoint.sh" >&2
exit 1
fi

# Fixture : un runner-dist "image" avec run.sh executable, un runner-home vide
# (volume frais) ou enregistre (.runner present).
make_dist() { # $1 = chemin dist
mkdir -p "$1"
printf '#!/bin/sh\nexit 0\n' > "$1/run.sh"
chmod +x "$1/run.sh"
printf '#!/bin/sh\nexit 0\n' > "$1/config.sh"
chmod +x "$1/config.sh"
}

HOME_DIR="$TEST_DIR/runner-home"
DIST_DIR="$TEST_DIR/runner-dist"
make_dist "$DIST_DIR"

# --- Test 1 : volume vide -> restauration des binaires depuis dist ----------
(
rm -rf "$HOME_DIR"; mkdir -p "$HOME_DIR"
out="$(RUNNER_HOME="$HOME_DIR" RUNNER_DIST="$DIST_DIR" bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
runner_restore_dist
' 2>&1)"; rc=$?
echo "$out" >> "$LOG"
if [ "$rc" -ne 0 ]; then ko "T1 restore exit 0 (rc=$rc)"; else ok "T1 restore exit 0"; fi
if [ -x "$HOME_DIR/run.sh" ]; then ok "T1 binaires restaures"; else ko "T1 run.sh absent apres restore"; fi
if echo "$out" | grep -q "restauration"; then ok "T1 restauration journalisee"; else ko "T1 aucune trace de restauration"; fi
)

# --- Test 2 : binaires presents -> restore silencieux (idempotent) -----------
(
out="$(RUNNER_HOME="$HOME_DIR" RUNNER_DIST="$DIST_DIR" bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
runner_restore_dist
' 2>&1)"; rc=$?
if [ "$rc" -ne 0 ]; then ko "T2 second restore exit 0 (rc=$rc)"; else ok "T2 second restore exit 0"; fi
if [ -n "$out" ]; then ko "T2 doit etre silencieux si run.sh present (sort: $out)"; else ok "T2 silencieux quand binaires presents"; fi
)

# --- Test 3 : .runner present -> enregistre, token non requis ---------------
(
touch "$HOME_DIR/.runner"
out="$(RUNNER_HOME="$HOME_DIR" bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
if runner_is_registered; then echo REGISTERED; else echo NOT-REGISTERED; fi
' 2>&1)"; rc=$?
# Aucune variable ACTIONS_RUNNER_INPUT_* definie : ne doit pas echouer.
if [ "$rc" -ne 0 ]; then ko "T3 detection .runner sans env (rc=$rc)"; else ok "T3 detection exit 0 sans token"; fi
if [ "$out" = "REGISTERED" ]; then ok "T3 .runner detecte"; else ko "T3 .runner non detecte (sort: $out)"; fi
)

# --- Test 4 : .runner absent -> NON enregistre (premier boot) ----------------
(
rm -f "$HOME_DIR/.runner"
out="$(RUNNER_HOME="$HOME_DIR" bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
if runner_is_registered; then echo REGISTERED; else echo NOT-REGISTERED; fi
' 2>&1)"; rc=$?
if [ "$rc" -ne 0 ]; then ko "T4 detection exit 0 (rc=$rc)"; else ok "T4 detection exit 0"; fi
if [ "$out" = "NOT-REGISTERED" ]; then ok "T4 absence .runner detectee"; else ko "T4 devrait lire NOT-REGISTERED (sort: $out)"; fi
)

# --- Test 5 : require_registration_env exige le token (premier boot) ---------
(
out="$(bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
require_registration_env
' 2>&1)"; rc=$?
if [ "$rc" -ne 0 ] && echo "$out" | grep -q "token manquant"; then
ok "T5 token exigee au premier enregistrement"
else ko "T5 require_registration_env devait echouer sur token manquant (rc=$rc, sort: $out)"; fi
)

# --- Test 6 : mode par defaut = ephemeral (retro-compatible) -----------------
(
out="$(bash -c '
. "'"$TEST_DIR"'/block.sh"
runner_mode
' 2>&1)"
if [ "$out" = "ephemeral" ]; then ok "T6 defaut ephemeral"; else ko "T6 defaut doit etre ephemeral (sort: $out)"; fi
out2="$(RUNNER_MODE=persistent bash -c '
. "'"$TEST_DIR"'/block.sh"
runner_mode
' 2>&1)"
if [ "$out2" = "persistent" ]; then ok "T6 RUNNER_MODE=persistent lu"; else ko "T6 RUNNER_MODE=persistent non respecte (sort: $out2)"; fi
)

# --- Test 7 : le bloc persistant ne modifie pas un home enregistre ----------
(
touch "$HOME_DIR/.runner"
RUNNER_HOME="$HOME_DIR" RUNNER_DIST="$DIST_DIR" bash -c '
set -euo pipefail
. "'"$TEST_DIR"'/block.sh"
runner_restore_dist
' >/dev/null 2>&1
if [ -f "$HOME_DIR/.runner" ] && [ -x "$HOME_DIR/run.sh" ]; then
ok "T7 .runner et binaires conserves"
else ko "T7 l'etat du home a ete altere"; fi
)

# --- Verdict agrege -----------------------------------------------------------
n_pass="$(grep -c '^PASS' "$RESULTS" || true)"
n_fail="$(grep -c '^FAIL' "$RESULTS" || true)"
echo "============================================================"
echo "entrypoint persistent mode : $n_pass PASS, $n_fail FAIL"
echo "============================================================"
if [ "$n_fail" -gt 0 ]; then
echo "(logs: $LOG)"
exit 1
fi
rm -rf "$TEST_DIR"
exit 0
Loading