Files
NewTube/docker/deploy-img.sh
T
bruno fa775497ee
CI / build-and-test (push) Successful in 14m0s
fix(deploy): 404 de rétention sur les images OCI index (buildx)
Le push réussissait puis le script sortait en 22 :

    curl: (22) The requested URL returned error: 404
    Write-Error: ... code d'erreur: 22

Deux causes cumulées, visibles seulement une fois MAX_VERSIONS dépassé.

1. Accept trop restrictif
   `docker build` via buildx produit un OCI image index
   (application/vnd.oci.image.index.v1+json), pas un manifest v2 plat.
   delete_by_tag demandait le digest avec
   `Accept: application/vnd.docker.distribution.manifest.v2+json` : le registre
   répond 404 — et non 406 — quand l'Accept ne couvre pas le type stocké.
   Le HEAD échouait donc sur TOUS les tags, y compris celui venait d'être
   poussé. On liste maintenant les quatre media types possibles.

2. La rétention pouvait tuer un déploiement réussi
   Sous `set -euo pipefail`, le curl de delete_by_tag n'était protégé par
   aucun `|| true` : son code de sortie 22 se propageait et tuait le script,
   transformant un push réussi en échec. Le ménage des anciennes versions est
   du best-effort et ne doit jamais faire échouer le push.

Symptôme d'origine : la rétention ne s'est jamais déclenchée tant que le
nombre de tags semver est resté <= MAX_VERSIONS (5). Au 7e tag, elle a
commencé à tourner et a fait échouer le script.

Vérifié : deploy-img.ps1 publie 1.0.20, `latest` aligné sur le digest de
l'image locale (sha256:25555d9ee4bf…), rétention purgée sans erreur.
2026-09-28 14:47:40 -04:00

300 lines
12 KiB
Bash

#!/usr/bin/env bash
# Script: deploy-img.sh
# Description: Pousse l'image Docker locale vers un registre HTTP (insecure), gère le versioning semver et la rétention
# Date: 2025-04-18
# Mise à jour: 2026-09-28 - Lecture des identifiants depuis .registry.env, échec rapide si le registre est protégé
set -euo pipefail
#####################################
# CONFIG UTILISATEUR #
#####################################
IMAGE_NAME="newtube-angular"
REGISTRY_HOST="docker-registry.dev.home"
REGISTRY_PORT="5000"
MAX_VERSIONS=5 # nombre de versions (semver) à conserver
LOCAL_TAG="latest" # tag local source
PUSH_LATEST="yes" # yes/no : pousser aussi :latest
#####################################
# DÉDUCTIONS & CONSTANTES #
#####################################
REGISTRY="${REGISTRY_HOST}:${REGISTRY_PORT}"
REMOTE_REPO="${REGISTRY}/${IMAGE_NAME}"
LOCAL_IMAGE="${IMAGE_NAME}:${LOCAL_TAG}"
TEMP_DIR="/tmp/docker-deploy-$(date +%s)"
CURL="curl -fsSL"
JQ_BIN="${JQ_BIN:-jq}" # possibilité de surcharger via env
# Headers pour obtenir le digest (manifest v2)
# L'Accept doit lister TOUS les media types possibles : `docker build` via
# buildx produit un OCI image index (application/vnd.oci.image.index.v1+json),
# pas un manifest v2 plat. Un registre répond 404 — pas 406 — quand l'Accept
# ne couvre pas le type stocké, ce qui faisait échouer la rétention.
ACCEPT_MANIFEST='application/vnd.oci.image.index.v1+json'
ACCEPT_MANIFEST="${ACCEPT_MANIFEST},application/vnd.docker.distribution.manifest.list.v2+json"
ACCEPT_MANIFEST="${ACCEPT_MANIFEST},application/vnd.oci.image.manifest.v1+json"
ACCEPT_MANIFEST="${ACCEPT_MANIFEST},application/vnd.docker.distribution.manifest.v2+json"
# Auth du registre. Ordre de priorité :
# 1. variables déjà exports (DOCKER_USERNAME / DOCKER_PASSWORD)
# 2. fichier .registry.env à côté du script (non versionné, cf. .gitignore)
# Le fichier permet à deploy-img.ps1 (qui exécute ce script dans WSL) de trouver
# les identifiants : WSL n'hérite pas des variables d'environnement de Windows.
# (Ce bloc est exécuté plus bas, après la définition de info/warn/error.)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ENV_FILE="${REGISTRY_ENV_FILE:-${SCRIPT_DIR}/.registry.env}"
AUTH_ARGS=()
#####################################
# UTILITAIRES #
#####################################
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[AVERT]\033[0m $*"; }
error() { echo -e "\033[1;31m[ERREUR]\033[0m $*" >&2; }
success() { echo -e "\033[1;32m[SUCCÈS]\033[0m $*"; }
cleanup() {
set +e
[[ -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
is_windows_shell() {
# Git Bash / MSYS / CYGWIN ou variable OS=Windows_NT
if [[ "${OS:-}" == "Windows_NT" ]] || uname -s | grep -qiE 'mingw|msys|cygwin'; then
return 0
fi
return 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { error "Commande requise manquante: $1"; exit 1; }
}
# Chargement des identifiants (voir commentaire en section CONFIG)
if [[ -f "${REGISTRY_ENV_FILE}" ]]; then
info "Lecture des identifiants depuis ${REGISTRY_ENV_FILE}"
set -a
# shellcheck disable=SC1090
source "${REGISTRY_ENV_FILE}"
set +a
fi
if [[ -n "${DOCKER_USERNAME:-}" && -n "${DOCKER_PASSWORD:-}" ]]; then
AUTH_ARGS=(-u "${DOCKER_USERNAME}:${DOCKER_PASSWORD}")
else
warn "Aucun identifiant registre (DOCKER_USERNAME / DOCKER_PASSWORD)."
warn "Créez ${REGISTRY_ENV_FILE} avec :"
printf '%s\n' " DOCKER_USERNAME=votre_utilisateur" " DOCKER_PASSWORD=votre_mot_de_passe"
fi
#####################################
# PRÉ-VÉRIFICATIONS #
#####################################
require_cmd docker
require_cmd curl
require_cmd sed
require_cmd grep
require_cmd awk
require_cmd sort
# Vérifier l'image locale
info "Vérification de l'image locale '${LOCAL_IMAGE}'…"
if ! docker image inspect "${LOCAL_IMAGE}" >/dev/null 2>&1; then
error "L'image locale ${LOCAL_IMAGE} n'existe pas. Construis-la d'abord (docker build …)."
exit 1
fi
# Vérifier config daemon: insecure-registries
info "Vérification de la config du daemon Docker (insecure-registries)…"
if ! docker info >/dev/null 2>&1; then
error "Impossible de contacter le daemon Docker. Est-il démarré ?"
exit 1
fi
if ! docker info 2>/dev/null \
| tr -d '\r' \
| awk '/Insecure Registries:/,/^$/ {print}' \
| grep -q -E "(^|[[:space:]])${REGISTRY_HOST}:${REGISTRY_PORT}([[:space:]]|$)"; then
error "Le daemon Docker n'a PAS '${REGISTRY}' dans insecure-registries."
if is_windows_shell; then
info "Sous Windows (Docker Desktop) : Settings → Docker Engine, ajoute dans le JSON :"
printf '%s\n' ' "insecure-registries": ["'"${REGISTRY}"'"]'
info "Puis clique 'Apply & Restart' et relance ce script."
else
info "Sous Linux : édite /etc/docker/daemon.json et ajoute par ex. :"
printf '%s\n' '{ "insecure-registries": ["'"${REGISTRY}"'"] }'
info "Puis : sudo systemctl restart docker"
fi
exit 1
fi
# Ping HTTP direct du registre (API v2).
# Un registre protégé répond 401 : cela prouve qu'il EST joignable, donc on
# accepte 200 comme 401 (et seulement eux). `-f` seul faisait échouer le script
# sur un registre authentifié.
info "Vérification HTTP directe du registre (http://${REGISTRY}/v2/)…"
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
--connect-timeout 10 -m 20 "http://${REGISTRY}/v2/" 2>/dev/null || echo "000")"
if [[ "${HTTP_CODE}" != "200" && "${HTTP_CODE}" != "401" ]]; then
error "Impossible de joindre http://${REGISTRY}/v2/ (code HTTP: ${HTTP_CODE})."
error "Le registre écoute-t-il bien en HTTP sur :${REGISTRY_PORT} ?"
exit 1
fi
# Un 401 sur un registre protégé SANS identifiants est bloquant : continuer
# produirait un push qui échoue en fin de course ("push access denied") après
# avoir déjà tagué l'image. On s'arrête ici avec un message actionnable.
if [[ "${HTTP_CODE}" == "401" && ${#AUTH_ARGS[@]} -eq 0 ]]; then
error "Le registre ${REGISTRY} exige une authentification et aucun identifiant n'est fourni."
error "Créez ${REGISTRY_ENV_FILE} :"
printf '%s\n' " DOCKER_USERNAME=votre_utilisateur" " DOCKER_PASSWORD=votre_mot_de_passe"
exit 1
fi
[[ "${HTTP_CODE}" == "401" ]] && info "Registre protégé (401) : authentification avec ${DOCKER_USERNAME}."
# Auth auprès du daemon (utile pour le push)
if [[ ${#AUTH_ARGS[@]} -gt 0 ]]; then
info "Authentification au registre (docker login)…"
# NB: docker login respecte le mode HTTP si daemon configuré en insecure-registries
if ! echo "${DOCKER_PASSWORD}" | docker login "${REGISTRY}" --username "${DOCKER_USERNAME}" --password-stdin >/dev/null 2>&1; then
error "docker login a échoué sur ${REGISTRY}. Vérifiez DOCKER_USERNAME / DOCKER_PASSWORD."
exit 1
fi
else
info "Aucun identifiant : le registre est ouvert, push sans authentification."
fi
mkdir -p "${TEMP_DIR}"
#####################################
# RÉCUPÉRATION DES TAGS #
#####################################
get_all_tags() {
# Renvoie la liste des tags (un par ligne) ou rien si vide/erreur
# Utilise jq si dispo, sinon un parseur simple.
local tags_json
if ! tags_json="$(${CURL} ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} "http://${REGISTRY}/v2/${IMAGE_NAME}/tags/list" 2>/dev/null)"; then
return 0
fi
if command -v "${JQ_BIN}" >/dev/null 2>&1; then
echo "${tags_json}" | ${JQ_BIN} -r '.tags[]?' 2>/dev/null || true
else
# fallback très simple (non robuste à tous les cas, mais suffisant ici)
echo "${tags_json}" \
| tr -d '\n' \
| sed -n 's/.*"tags":[[]\([^]]*\)[]].*/\1/p' \
| tr -d '"' \
| tr ',' '\n' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
fi
}
filter_semver() {
# Garde uniquement X.Y.Z (numériques)
grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true
}
increment_patch() {
# Lit un tag semver X.Y.Z et augmente Z
local tag="$1"
local major minor patch
IFS='.' read -r major minor patch <<< "${tag}"
echo "${major}.${minor}.$((patch+1))"
}
info "Récupération des tags existants…"
ALL_TAGS="$(get_all_tags || true)"
SEMVER_TAGS="$(printf '%s\n' "${ALL_TAGS}" | filter_semver | sort -V || true)"
if [[ -z "${SEMVER_TAGS}" ]]; then
NEW_TAG="1.0.0"
else
LATEST_TAG="$(printf '%s\n' "${SEMVER_TAGS}" | tail -n1)"
NEW_TAG="$(increment_patch "${LATEST_TAG}")"
fi
info "Tag retenu pour cette release : ${NEW_TAG}"
#####################################
# TAG + PUSH (HTTP via daemon)
#####################################
info "Taggage local → ${REMOTE_REPO}:${NEW_TAG}"
docker tag "${LOCAL_IMAGE}" "${REMOTE_REPO}:${NEW_TAG}"
if [[ "${PUSH_LATEST}" == "yes" ]]; then
info "Taggage local → ${REMOTE_REPO}:latest"
docker tag "${LOCAL_IMAGE}" "${REMOTE_REPO}:latest"
fi
info "Push de ${REMOTE_REPO}:${NEW_TAG} (HTTP via insecure-registries)…"
docker push --disable-content-trust "${REMOTE_REPO}:${NEW_TAG}"
if [[ "${PUSH_LATEST}" == "yes" ]]; then
info "Push de ${REMOTE_REPO}:latest…"
docker push --disable-content-trust "${REMOTE_REPO}:latest"
fi
#####################################
# RÉTENTION: SUPPRIMER ANCIENS #
#####################################
delete_by_tag() {
# Supprime un manifest par son tag en récupérant le digest via HEAD
local tag="$1"
local digest
# La rétention est du ménage best-effort : elle ne doit JAMAIS faire échouer
# un push qui a réussi. D'où les `|| true` (et le `set +e` local) — sans eux,
# le moindre curl en erreur (404 sur un tag déjà purgé, registre momentanément
# indisponible) faisait sortir le script avec le code curl sous `set -e`.
# On demande le digest via HEAD + Accept (index OCI + manifest v2).
set +e
digest="$(curl -fsSI ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} -H "Accept: ${ACCEPT_MANIFEST}" "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${tag}" 2>/dev/null \
| tr -d '\r' \
| awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}' \
| tail -n1)"
set -e
if [[ -z "${digest}" ]]; then
warn "Digest introuvable pour ${tag} (manifest absent ?). Skip."
return 0
fi
info "Suppression manifest ${tag} (digest: ${digest})…"
# La delete API ne renvoie rien en cas de succès (204)
if ! curl -fsS ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} -X DELETE "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${digest}" >/dev/null 2>&1; then
warn "Échec suppression manifest pour ${tag} (digest: ${digest})."
fi
}
# Mettre à jour la liste après push
ALL_TAGS="$(get_all_tags || true)"
SEMVER_TAGS="$(printf '%s\n' "${ALL_TAGS}" | filter_semver | sort -V || true)"
if [[ -n "${SEMVER_TAGS}" ]]; then
COUNT="$(printf '%s\n' "${SEMVER_TAGS}" | wc -l | awk '{print $1}')"
if (( COUNT > MAX_VERSIONS )); then
TO_DELETE_COUNT=$(( COUNT - MAX_VERSIONS ))
# On supprime les plus anciennes
OLDEST="$(printf '%s\n' "${SEMVER_TAGS}" | head -n "${TO_DELETE_COUNT}")"
while IFS= read -r old_tag; do
[[ -z "${old_tag}" ]] && continue
# Ne jamais supprimer le tag que l'on vient de pousser (sécurité)
if [[ "${old_tag}" == "${NEW_TAG}" ]]; then
continue
fi
delete_by_tag "${old_tag}"
done <<< "${OLDEST}"
fi
fi
#####################################
# NETTOYAGE LOCAL OPTIONNEL #
#####################################
info "Nettoyage des tags locaux temporaires…"
set +e
docker rmi "${REMOTE_REPO}:${NEW_TAG}" >/dev/null 2>&1
[[ "${PUSH_LATEST}" == "yes" ]] && docker rmi "${REMOTE_REPO}:latest" >/dev/null 2>&1
set -e
success "Déploiement terminé. Version publiée : ${NEW_TAG}"
exit 0