From 2b91469b72b3469c4e4f47c0ea269ba9acbac0a6 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 20 Sep 2026 18:05:00 +0200 Subject: [PATCH] feat(runners,#14329): mode persistent du runner conteneurise -- enregistrement unique, restart sans token RUNNER_MODE=persistent dans l'entrypoint : le conteneur --restart unless-stopped fait son propre polling, le superviseur hote (mort au logoff, reboot 2026-09-02 : flotte retombee a 1 runner) devient inutile. - volume de config PAR SLOT sur /opt/runner (.runner/.credentials/ binaires) ; l'image extrait aussi vers /opt/runner-dist et l'entrypoint restaure un volume cree vide depuis cette source vierge - token requis au PREMIER enregistrement seulement : aux restarts, .runner present => run.sh direct, aucune variable requise - pas de trap config.sh remove (desenregistrer au EXIT tuerait la propriete meme du mode) ; --replace a l'enregistrement couvre la recreation du volume - --ephemeral reste le defaut : chemin ephemeral inchange, retro-compatible Tests : test_entrypoint_persistent.sh 13/13 PASS (extraction du bloc reel + fixtures de repertoires, methode test_entrypoint_disarm.sh) ; non-regression disarm 10/10, work_cache_health 22/22. Doc : docs/ci/self-hosted-runners.md -- cout de la non-ephemeralite ecrit (etat persistant entre jobs, hooks _work au boot du conteneur et non par job, slot offline consomme son inscription) et pourquoi accepte (aucun pull_request_target self-hosted, garde fork/payload). Validation systemctl restart docker et mesure checkout avant/apres : au deploiement po-2024, jamais ai-01. See #14329 Co-Authored-By: Claude Sonnet 5 --- docs/ci/self-hosted-runners.md | 33 ++++ scripts/ci/docker/linux-runner/Dockerfile | 19 ++- scripts/ci/docker/linux-runner/entrypoint.sh | 72 ++++++++- .../test_entrypoint_persistent.sh | 146 ++++++++++++++++++ 4 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 scripts/ci/docker/linux-runner/test_entrypoint_persistent.sh diff --git a/docs/ci/self-hosted-runners.md b/docs/ci/self-hosted-runners.md index 22bfb85b19..bf6a9affa1 100644 --- a/docs/ci/self-hosted-runners.md +++ b/docs/ci/self-hosted-runners.md @@ -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 : diff --git a/scripts/ci/docker/linux-runner/Dockerfile b/scripts/ci/docker/linux-runner/Dockerfile index 80fb72bd5f..ec0887af08 100644 --- a/scripts/ci/docker/linux-runner/Dockerfile +++ b/scripts/ci/docker/linux-runner/Dockerfile @@ -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 @@ -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"] diff --git a/scripts/ci/docker/linux-runner/entrypoint.sh b/scripts/ci/docker/linux-runner/entrypoint.sh index 5cee2c552b..9235ba874b 100644 --- a/scripts/ci/docker/linux-runner/entrypoint.sh +++ b/scripts/ci/docker/linux-runner/entrypoint.sh @@ -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) --------- @@ -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 @@ -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 ; diff --git a/scripts/ci/docker/linux-runner/test_entrypoint_persistent.sh b/scripts/ci/docker/linux-runner/test_entrypoint_persistent.sh new file mode 100644 index 0000000000..72d8f47b38 --- /dev/null +++ b/scripts/ci/docker/linux-runner/test_entrypoint_persistent.sh @@ -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