Files
hcornet 53a84cd2f0
Compose CI / lint-ci-scripts (push) Successful in 6s
Compose CI / compose-verify (push) Successful in 1m6s
new config
2026-09-18 15:14:01 +02:00

479 lines
15 KiB
Bash

#!/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 "$@"