479 lines
15 KiB
Bash
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 "$@"
|