#!/usr/bin/env bash # Vérification d'une stack Docker Compose en CI, sur le démon Docker de l'hôte, # sans aucune interaction avec les conteneurs, réseaux et volumes de production. # # Usage : compose-ci.sh prepare|up|verify|logs|cleanup # # Aucune variable ni aucun secret à configurer : les réglages sont fixés ci-dessous # et identiques pour tous les dépôts. # Fichier facultatif dans le dépôt : .env.example (utilisé si .env est absent), # nécessaire seulement si le compose utilise des ${VARIABLE} sans valeur par défaut. # Un dépôt sans fichier Compose (ex. le dépôt Template) ne teste rien et reste au vert. set -Eeuo pipefail readonly HOST_ROOT="/var/tmp/gitea-ci" readonly HELPER_IMAGE="python:3.13-alpine3.22" readonly CI_MARKER_LABEL="tips-of-mine.ci" readonly STABILIZATION_SECONDS=15 readonly POLL_SECONDS=5 readonly WAIT_TIMEOUT_SECONDS=300 readonly HTTP_TIMEOUT_SECONDS=300 readonly HTTP_TARGET_LABEL="tips-of-mine.ci.http" readonly MIN_AVAILABLE_MB=3072 readonly LOG_LINES=50 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR readonly WORK_DIR="${RUNNER_TEMP:-/tmp}/compose-ci" readonly CI_FILE="${WORK_DIR}/compose.ci.json" readonly COUNT_FILE="${WORK_DIR}/services.count" readonly MASK_FILE="${WORK_DIR}/mask.b64" readonly STARTED_FILE="${WORK_DIR}/started.epoch" readonly SENSITIVE_NAME_RE='PASS|PWD|SECRET|TOKEN|KEY|CREDENTIAL|PRIVATE|AUTH|DSN|URI|URL|SALT|COOKIE' readonly MASK_MIN_LENGTH=6 # Interroge une URL jusqu'à obtenir une réponse HTTP < 500 ou jusqu'à l'échéance. # Les redirections ne sont pas suivies : une réponse 3xx prouve que l'application répond. PROBE_PY="$(cat <<'PY' import os, ssl, sys, time, urllib.error, urllib.request url = os.environ["PROBE_URL"] start = float(os.environ["PROBE_START"]) deadline = float(os.environ["PROBE_DEADLINE"]) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): return None opener = urllib.request.build_opener( NoRedirect, urllib.request.HTTPSHandler(context=ssl._create_unverified_context()) ) last = "aucune réponse" while True: code = None try: with opener.open(url, timeout=5) as response: code = response.status except urllib.error.HTTPError as error: code = error.code except Exception as error: last = str(getattr(error, "reason", "") or type(error).__name__) if code is not None: if code < 500: print(f"HTTP {code} disponible en {time.time() - start:.0f} s") sys.exit(0) last = f"HTTP {code}" if time.time() >= deadline: print(f"ÉCHEC ({last}) indisponible après {time.time() - start:.0f} s") sys.exit(1) time.sleep(2) PY )" readonly PROBE_PY PROJECT="" SERVICE_COUNT=0 ENV_FILE="" ENV_SOURCE="" die() { echo "ERREUR : $*" >&2 exit 1 } project_name() { local repo run_id attempt name repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY absent}" repo="${repo##*/}" repo="$(printf '%s' "${repo,,}" | tr -c 'a-z0-9' '-' | tr -s '-')" repo="${repo#-}" repo="${repo:0:40}" repo="${repo%-}" run_id="${GITHUB_RUN_ID:?GITHUB_RUN_ID absent}" attempt="${GITHUB_RUN_ATTEMPT:-1}" name="ci-${repo}-${run_id}-${attempt}" [[ "$name" =~ ^ci-[a-z0-9]([a-z0-9-]*[a-z0-9])?-[0-9]+-[0-9]+$ ]] \ || die "nom de projet de CI invalide : ${name}" printf '%s' "$name" } compose_file() { local candidate for candidate in docker-compose.yml docker-compose.yaml compose.yaml compose.yml; do if [[ -f "${GITHUB_WORKSPACE}/${candidate}" ]]; then printf '%s' "$candidate" return fi done } resolve_env_file() { if [[ -f "${GITHUB_WORKSPACE}/.env" ]]; then ENV_FILE="${GITHUB_WORKSPACE}/.env" ENV_SOURCE=".env du dépôt" elif [[ -f "${GITHUB_WORKSPACE}/.env.example" ]]; then ENV_FILE="${GITHUB_WORKSPACE}/.env.example" ENV_SOURCE=".env.example du dépôt" else ENV_FILE="" ENV_SOURCE="aucun" fi } # Ajoute au masquage les valeurs des variables sensibles du fichier d'environnement. mask_env_file() { local line name value [[ -n "$ENV_FILE" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]] || continue name="${BASH_REMATCH[2]}" value="${BASH_REMATCH[3]}" [[ "$name" =~ $SENSITIVE_NAME_RE ]] || continue value="${value%$'\r'}" value=${value#[\"\']} value=${value%[\"\']} (( ${#value} >= MASK_MIN_LENGTH )) || continue printf '%s' "$value" | base64 -w0 >> "$MASK_FILE" printf '\n' >> "$MASK_FILE" done < "$ENV_FILE" } compose() { docker compose --ansi never \ --project-name "$PROJECT" \ --project-directory "$GITHUB_WORKSPACE" \ -f "$CI_FILE" "$@" } helper() { docker run --rm --network none "$@" } helper_on() { local network="$1" shift [[ "$network" == "${PROJECT}_"* ]] || die "réseau hors du projet de CI refusé : ${network}" docker run --rm --network "$network" "$@" } load_state() { PROJECT="$(project_name)" [[ -f "$COUNT_FILE" ]] \ || die "configuration de CI absente : l'étape de préparation n'a pas abouti" SERVICE_COUNT="$(<"$COUNT_FILE")" [[ "$SERVICE_COUNT" =~ ^[0-9]+$ ]] || die "nombre de services illisible" if (( SERVICE_COUNT > 0 )) && [[ ! -f "$CI_FILE" ]]; then die "configuration de CI absente : l'étape de préparation n'a pas abouti" fi } check_memory() { local min="$MIN_AVAILABLE_MB" available available="$(awk '/^MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo)" [[ "$available" =~ ^[0-9]+$ ]] || die "mémoire disponible illisible" echo "Mémoire disponible sur l'hôte : ${available} Mio (seuil : ${min} Mio)" (( available >= min )) \ || die "mémoire insuffisante sur l'hôte : démarrage annulé pour protéger la production" } assert_isolated() { local key for key in '"container_name":' '"ports":' '"external":'; do if grep -qF "$key" "$CI_FILE"; then die "la configuration de CI contient encore ${key} : arrêt par sécurité" fi done } cmd_prepare() { local file raw errors unset_vars local -a env_args=() PROJECT="$(project_name)" install -d -m 0700 "$WORK_DIR" file="$(compose_file)" if [[ -z "$file" ]]; then echo "Aucun fichier Compose à la racine du dépôt : rien à tester" echo 0 > "$COUNT_FILE" return 0 fi echo "Fichier Compose : ${file}" check_memory resolve_env_file echo "Variables d'environnement : ${ENV_SOURCE}" if [[ -n "$ENV_FILE" ]]; then env_args=(--env-file "$ENV_FILE") fi echo "Copie du dépôt vers ${HOST_ROOT}/${PROJECT} sur l'hôte" tar -C "$GITHUB_WORKSPACE" --exclude=./.git -cf - . \ | helper -i -v "${HOST_ROOT}/${PROJECT}:/dst" "$HELPER_IMAGE" tar -C /dst -xf - echo "Réécriture de la configuration Compose" raw="${WORK_DIR}/rewrite.out" errors="${WORK_DIR}/config.err" if ! docker compose --ansi never \ --project-name "$PROJECT" \ --project-directory "$GITHUB_WORKSPACE" \ "${env_args[@]}" \ -f "${GITHUB_WORKSPACE}/${file}" \ config --format json 2> "$errors" \ | helper -i \ -e CI_PROJECT="$PROJECT" \ -e CI_WORKSPACE="$GITHUB_WORKSPACE" \ -e CI_HOST_DIR="${HOST_ROOT}/${PROJECT}" \ -v "${HOST_ROOT}/${PROJECT}:/ci" \ "$HELPER_IMAGE" python3 -c "$(<"${SCRIPT_DIR}/compose-ci-rewrite.py")" \ > "$raw"; then cat "$errors" >&2 die "la configuration Compose n'a pas pu être résolue" fi unset_vars="$(sed -nE 's/.*The \\?"([A-Za-z_][A-Za-z0-9_]*)\\?" variable is not set.*/\1/p' "$errors" | sort -u | paste -sd ' ' -)" if [[ -n "$unset_vars" ]]; then die "variables non définies (${ENV_SOURCE}) : ${unset_vars}" fi if [[ -s "$errors" ]]; then cat "$errors" >&2 fi sed -n '1p' "$raw" > "$COUNT_FILE" sed -n '2p' "$raw" > "$CI_FILE" sed -n '3,$p' "$raw" > "$MASK_FILE" rm -f "$raw" chmod 0600 "$CI_FILE" "$MASK_FILE" mask_env_file load_state assert_isolated compose config --quiet echo "Configuration de CI validée (${SERVICE_COUNT} service(s))" } cmd_up() { load_state if (( SERVICE_COUNT == 0 )); then echo "Aucun service testable dans ce dépôt : rien à démarrer" return 0 fi compose up --detach --build --quiet-pull date +%s > "$STARTED_FILE" } evaluate_state() { local ids id name status code health verdict STATE_PENDING=0 STATE_FAILED=0 STATE_TABLE="$(printf '%-50s %-11s %-5s %-10s %s' CONTENEUR ETAT CODE SANTE VERDICT)"$'\n' mapfile -t ids < <(compose ps --all --quiet | grep -v '^$' || true) if (( ${#ids[@]} == 0 )); then STATE_FAILED=1 STATE_TABLE+="aucun conteneur créé"$'\n' return fi for id in "${ids[@]}"; do IFS='|' read -r name status code health < <( docker inspect --format \ '{{.Name}}|{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}-{{end}}' \ "$id" ) case "${status}:${health}" in running:- | running:healthy) verdict="OK" ;; running:starting | running:unhealthy | created:* | restarting:*) verdict="ATTENTE"; STATE_PENDING=1 ;; exited:*) if [[ "$code" == "0" ]]; then verdict="TERMINÉ" else verdict="ÉCHEC"; STATE_FAILED=1 fi ;; *) verdict="ÉCHEC"; STATE_FAILED=1 ;; esac STATE_TABLE+="$(printf '%-50s %-11s %-5s %-10s %s' "${name#/}" "$status" "$code" "$health" "$verdict")"$'\n' done } single_exposed_port() { local id="$1" entry local -a ports=() for entry in $(docker inspect --format '{{range $p, $v := .Config.ExposedPorts}}{{$p}} {{end}}' "$id"); do [[ "$entry" == */tcp ]] && ports+=("${entry%/tcp}") done (( ${#ports[@]} == 1 )) && printf '%s' "${ports[0]}" } probe() { local network="$1" url="$2" started="$3" result status=0 result="$(helper_on "$network" \ -e PROBE_URL="$url" \ -e PROBE_START="$started" \ -e PROBE_DEADLINE="$(( started + HTTP_TIMEOUT_SECONDS ))" \ "$HELPER_IMAGE" python3 -c "$PROBE_PY" 2>&1)" || status=$? printf '%-70s %s\n' "$url" "$result" return "$status" } http_checks() { local started ids id name spec networks network port target scheme tested=0 status=0 local -a targets=() [[ -f "$STARTED_FILE" ]] || die "heure de démarrage inconnue : l'étape de démarrage n'a pas abouti" started="$(<"$STARTED_FILE")" echo echo "Test HTTP (délai maximal : ${HTTP_TIMEOUT_SECONDS} s après le démarrage)" mapfile -t ids < <(compose ps --quiet | grep -v '^$' || true) for id in "${ids[@]}"; do IFS='|' read -r name spec networks < <( docker inspect --format \ "{{.Name}}|{{index .Config.Labels \"${HTTP_TARGET_LABEL}\"}}|{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}} {{end}}" \ "$id" ) [[ -n "$spec" ]] || continue name="${name#/}" network="${networks%% *}" if [[ -z "$network" ]]; then echo "${name} : aucun réseau propre, test HTTP ignoré" continue fi if [[ "$spec" == "auto" ]]; then port="$(single_exposed_port "$id" || true)" if [[ -z "$port" ]]; then echo "${name} : port HTTP indéterminable (aucun label de port Traefik, ports exposés multiples ou absents), test HTTP ignoré" continue fi spec="http:${port}" fi IFS=',' read -ra targets <<<"$spec" for target in "${targets[@]}"; do scheme="${target%%:*}" port="${target##*:}" tested=1 probe "$network" "${scheme}://${name}:${port}/" "$started" || status=1 done done if (( ! tested )); then echo "Aucun service exposé par Traefik : test HTTP non applicable" fi return "$status" } cmd_verify() { local timeout deadline stable=0 load_state if (( SERVICE_COUNT == 0 )); then echo "Rien à vérifier" return 0 fi timeout="$WAIT_TIMEOUT_SECONDS" deadline=$(( SECONDS + timeout )) while :; do evaluate_state if (( STATE_FAILED )); then printf '%s' "$STATE_TABLE" die "au moins un conteneur est en échec" fi if (( ! STATE_PENDING )); then if (( stable )); then printf '%s' "$STATE_TABLE" echo "Tous les conteneurs sont démarrés et stables" http_checks || die "au moins un service web ne répond pas dans le délai" return 0 fi stable=1 sleep "$STABILIZATION_SECONDS" continue fi stable=0 if (( SECONDS >= deadline )); then printf '%s' "$STATE_TABLE" die "délai de ${timeout} s dépassé avant que tous les conteneurs soient prêts" fi sleep "$POLL_SECONDS" done } cmd_logs() { local logs encoded secret if [[ ! -f "$CI_FILE" ]]; then echo "Pas de configuration de CI : aucun journal à afficher" return 0 fi load_state compose ps --all --format 'table {{.Service}}\t{{.State}}\t{{.Status}}' || true logs="$(compose logs --no-color --tail "$LOG_LINES" 2>&1 || true)" if [[ -s "$MASK_FILE" ]]; then while IFS= read -r encoded; do [[ -n "$encoded" ]] || continue secret="$(printf '%s' "$encoded" | base64 -d)" [[ -n "$secret" ]] && logs="${logs//"$secret"/***}" done < "$MASK_FILE" fi printf '%s\n' "$logs" } remove_by_label() { local kind="$1" filter="label=com.docker.compose.project=${PROJECT}" case "$kind" in container) docker ps -aq --filter "$filter" | xargs -r docker rm -f >/dev/null ;; network) docker network ls -q --filter "$filter" | xargs -r docker network rm >/dev/null ;; volume) docker volume ls -q --filter "$filter" | xargs -r docker volume rm >/dev/null ;; esac } cmd_cleanup() { local markers foreign status=0 PROJECT="$(project_name)" markers="$(docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT}" \ --format "[{{.Label \"${CI_MARKER_LABEL}\"}}]")" \ || die "impossible de lister les conteneurs du projet ${PROJECT}" foreign="$(grep -cvxF '[true]' <<<"$markers" || true)" [[ -n "$markers" ]] || foreign=0 if (( foreign > 0 )); then die "${foreign} conteneur(s) du projet ${PROJECT} sans marqueur de CI : nettoyage annulé" fi if [[ -f "$CI_FILE" ]]; then compose down --volumes --remove-orphans --rmi local --timeout 30 || status=1 fi remove_by_label container || status=1 remove_by_label network || status=1 remove_by_label volume || status=1 # shellcheck disable=SC2016 # $1 est développé par le sh du conteneur helper -v "${HOST_ROOT}:/ci-root" "$HELPER_IMAGE" \ sh -c 'rm -rf -- "/ci-root/$1"' _ "$PROJECT" || status=1 if (( status )); then die "nettoyage incomplet du projet ${PROJECT}" fi echo "Projet ${PROJECT} nettoyé" } main() { [[ -n "${GITHUB_WORKSPACE:-}" ]] || die "GITHUB_WORKSPACE absent" case "${1:-}" in prepare) cmd_prepare ;; up) cmd_up ;; verify) cmd_verify ;; logs) cmd_logs ;; cleanup) cmd_cleanup ;; *) die "usage : $0 prepare|up|verify|logs|cleanup" ;; esac } main "$@"