new config
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user