← Retour
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════════════
# DOCKER.SH - Stratégie de déploiement Docker avec Zero-Downtime
#
# Stratégie :
# 1. Build et push nouvelle image
# 2. Démarrer nouveau container sur port temporaire (staging)
# 3. Health check sur staging
# 4. Swap atomique : stop ancien → start nouveau sur port prod
# 5. Nginx n'a jamais besoin de changer
# ═══════════════════════════════════════════════════════════════════════════
REGISTRY_URL="${REGISTRY_URL:-registry.33800.nowhere84.com}"
TEMPLATES_DIR="${SCRIPTS_DIR}/templates"
# Port temporaire = port prod + 10000 (ex: 5501 → 15501)
STAGING_PORT_OFFSET=10000
# Blue-Green state directory (on target)
DEPLOY_STATE_DIR="/opt/deploy-state"
# ───────────────────────────────────────────────────────────────────────────
# Fonction principale de déploiement Docker
# ───────────────────────────────────────────────────────────────────────────
deploy_docker() {
local image_name="${CFG_name}"
local image_tag="${COMMIT:-latest}"
local full_image="${REGISTRY_URL}/gouroubleu/${image_name}:${image_tag}"
local latest_image="${REGISTRY_URL}/gouroubleu/${image_name}:latest"
log_info "Image: $full_image"
# 1. Déterminer le Dockerfile
local dockerfile="Dockerfile"
if [[ -n "${CFG_dockerfile:-}" ]]; then
dockerfile="${CFG_dockerfile}"
elif [[ ! -f "${PROJECT_DIR}/Dockerfile" ]]; then
generate_dockerfile
dockerfile=".Dockerfile.generated"
fi
# 2. Build
log_step "Build de l'image Docker..."
# Build args (CFG_build_args ou NPM_TOKEN par défaut pour packages privés)
local build_args=""
if [[ -n "${CFG_build_args:-}" ]]; then
for arg in ${CFG_build_args}; do
build_args+=" --build-arg ${arg}"
done
fi
# Toujours passer CI_JOB_TOKEN comme NPM_TOKEN si disponible (pour packages npm privés)
if [[ -n "${CI_JOB_TOKEN:-}" ]]; then
build_args+=" --build-arg NPM_TOKEN=${CI_JOB_TOKEN}"
fi
DOCKER_BUILDKIT=1 docker build ${build_args} -t "$full_image" -t "$latest_image" -f "${PROJECT_DIR}/${dockerfile}" "$PROJECT_DIR"
log_success "Build OK"
# 3. Push vers registry
log_step "Push vers registry..."
docker push "$full_image"
docker push "$latest_image"
log_success "Push OK"
# 4. Deploy selon la stratégie configurée
local strategy="${CFG_deploy_strategy:-simple}"
log_info "Stratégie: ${strategy}"
# Pre-deploy hooks (abort if failure)
if type -t run_hooks &>/dev/null; then
run_hooks "pre_deploy" || return 1
fi
case "$strategy" in
zero-downtime|zdt)
if [[ "$TARGET_LOCAL" == "true" ]]; then
deploy_zero_downtime_local "$latest_image"
else
deploy_zero_downtime_remote "$latest_image"
fi
;;
simple|*)
# Stratégie simple : stop → start (quelques secondes de downtime)
if [[ "$TARGET_LOCAL" == "true" ]]; then
deploy_simple_local "$latest_image"
else
deploy_simple_remote "$latest_image"
fi
;;
esac
# Post-deploy hooks (warning only if failure)
if type -t run_hooks &>/dev/null; then
run_hooks "post_deploy" || true
fi
log_success "Déploiement Docker terminé"
}
# ───────────────────────────────────────────────────────────────────────────
# BUILD DOCKER - Build image uniquement (stage: build)
# ───────────────────────────────────────────────────────────────────────────
build_docker() {
local image_name="${CFG_name}"
local image_tag="${COMMIT:-latest}"
local full_image="${REGISTRY_URL}/gouroubleu/${image_name}:${image_tag}"
local latest_image="${REGISTRY_URL}/gouroubleu/${image_name}:latest"
log_info "Image: $full_image"
# Déterminer le Dockerfile
local dockerfile="Dockerfile"
if [[ -n "${CFG_dockerfile:-}" ]]; then
dockerfile="${CFG_dockerfile}"
elif [[ ! -f "${PROJECT_DIR}/Dockerfile" ]]; then
generate_dockerfile
dockerfile=".Dockerfile.generated"
fi
# Build args
local build_args=""
if [[ -n "${CFG_build_args:-}" ]]; then
for arg in ${CFG_build_args}; do
build_args+=" --build-arg ${arg}"
done
fi
if [[ -n "${CI_JOB_TOKEN:-}" ]]; then
build_args+=" --build-arg NPM_TOKEN=${CI_JOB_TOKEN}"
fi
log_step "Build de l'image Docker..."
DOCKER_BUILDKIT=1 docker build ${build_args} -t "$full_image" -t "$latest_image" -f "${PROJECT_DIR}/${dockerfile}" "$PROJECT_DIR"
log_success "Build OK: $full_image"
# Exporter les variables pour les stages suivants
export BUILT_IMAGE_FULL="$full_image"
export BUILT_IMAGE_LATEST="$latest_image"
}
# ───────────────────────────────────────────────────────────────────────────
# PUSH DOCKER - Push image vers registry (stage: push)
# ───────────────────────────────────────────────────────────────────────────
push_docker() {
local image_name="${CFG_name}"
local image_tag="${COMMIT:-latest}"
local full_image="${BUILT_IMAGE_FULL:-${REGISTRY_URL}/gouroubleu/${image_name}:${image_tag}}"
local latest_image="${BUILT_IMAGE_LATEST:-${REGISTRY_URL}/gouroubleu/${image_name}:latest}"
log_step "Push vers registry..."
docker push "$full_image"
docker push "$latest_image"
log_success "Push OK: $full_image"
}
# ───────────────────────────────────────────────────────────────────────────
# BLUE-GREEN STATE MANAGEMENT
# ───────────────────────────────────────────────────────────────────────────
# Lire l'état blue-green depuis le state file sur le target
# Exporte: BG_ACTIVE_COLOR, BG_ACTIVE_PORT, BG_INACTIVE_COLOR, BG_INACTIVE_PORT,
# BG_BLUE_PORT, BG_GREEN_PORT, BG_LAST_IMAGE
read_deploy_state() {
local name="${CFG_container_name:-${CFG_name}}"
local port="${CFG_port:-3000}"
local green_port="${CFG_deploy_green_port:-$((port + 1))}"
local state_file="${DEPLOY_STATE_DIR}/${name}.env"
local target_host="${TARGET_HOST:-}"
local target_user="${TARGET_USER:-gouroubleu}"
# Valeurs par défaut
export BG_BLUE_PORT="$port"
export BG_GREEN_PORT="$green_port"
# Lire le state file
local state_content=""
if [[ -n "$target_host" ]]; then
state_content=$(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "${target_user}@${target_host}" "cat ${state_file} 2>/dev/null" 2>/dev/null) || true
else
state_content=$(cat "$state_file" 2>/dev/null) || true
fi
if [[ -n "$state_content" ]]; then
# Charger les variables depuis le state file
eval "$state_content"
export BG_ACTIVE_COLOR="${ACTIVE_COLOR:-blue}"
export BG_ACTIVE_PORT="${ACTIVE_PORT:-$port}"
export BG_LAST_IMAGE="${LAST_IMAGE:-}"
log_info "State file: active=${BG_ACTIVE_COLOR} port=${BG_ACTIVE_PORT}"
else
# Premier deploy : on considère que le service tourne sur le port principal (blue)
export BG_ACTIVE_COLOR="blue"
export BG_ACTIVE_PORT="$port"
export BG_LAST_IMAGE=""
log_info "Pas de state file, bootstrap: blue=${port} green=${green_port}"
fi
# Calculer le slot inactif
if [[ "$BG_ACTIVE_COLOR" == "blue" ]]; then
export BG_INACTIVE_COLOR="green"
export BG_INACTIVE_PORT="$BG_GREEN_PORT"
else
export BG_INACTIVE_COLOR="blue"
export BG_INACTIVE_PORT="$BG_BLUE_PORT"
fi
log_info "Deploiement sur: ${BG_INACTIVE_COLOR} (port ${BG_INACTIVE_PORT})"
}
# Écrire le nouvel état après un switch réussi
write_deploy_state() {
local new_color="$1"
local new_port="$2"
local new_image="$3"
local name="${CFG_container_name:-${CFG_name}}"
local state_file="${DEPLOY_STATE_DIR}/${name}.env"
local target_host="${TARGET_HOST:-}"
local target_user="${TARGET_USER:-gouroubleu}"
local state_content="ACTIVE_COLOR=${new_color}
ACTIVE_PORT=${new_port}
BLUE_PORT=${BG_BLUE_PORT}
GREEN_PORT=${BG_GREEN_PORT}
LAST_IMAGE=${new_image}
LAST_DEPLOY=$(date -Iseconds)"
if [[ -n "$target_host" ]]; then
ssh "${target_user}@${target_host}" "mkdir -p ${DEPLOY_STATE_DIR} && cat > ${state_file}" <<< "$state_content"
else
mkdir -p "$DEPLOY_STATE_DIR"
echo "$state_content" > "$state_file"
fi
log_success "State sauvé: ${new_color}:${new_port}"
}
# ───────────────────────────────────────────────────────────────────────────
# BLUE-GREEN DEPLOYMENT - Local
# ───────────────────────────────────────────────────────────────────────────
deploy_bluegreen_local() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local health="${CFG_health:-/health}"
local drain_seconds="${CFG_deploy_drain_seconds:-10}"
# Lire l'état actuel
read_deploy_state
local new_container="${name}-${BG_INACTIVE_COLOR}"
local old_container="${name}-${BG_ACTIVE_COLOR}"
local new_port="$BG_INACTIVE_PORT"
local old_port="$BG_ACTIVE_PORT"
local internal_port="${CFG_port:-3000}"
log_step "Blue-Green Deploy: ${BG_ACTIVE_COLOR}:${old_port} → ${BG_INACTIVE_COLOR}:${new_port}"
cd "$PROJECT_DIR"
# 1. Générer compose pour le nouveau container (sur le port inactif)
log_step "1/5 Démarrage ${BG_INACTIVE_COLOR} sur port ${new_port}..."
# Supprimer ancien container inactif s'il traine
docker rm -f "$new_container" 2>/dev/null || true
# Générer le compose avec le port inactif
# On réutilise generate_prod_compose mais avec un container_name et port différents
local orig_container_name="${CFG_container_name:-}"
CFG_container_name="$new_container"
generate_prod_compose "$image" "$new_container" "$new_port" "$health"
CFG_container_name="${orig_container_name:-}"
docker compose -f ".compose.prod.yml" pull || true
docker compose -f ".compose.prod.yml" up -d
# 2. Health check sur le nouveau container
log_step "2/5 Health check ${BG_INACTIVE_COLOR}..."
local docker_host="${TARGET_HOST:-172.17.0.1}"
local health_url="http://${docker_host}:${new_port}${health}"
local max_wait=60
local waited=0
while [[ $waited -lt $max_wait ]]; do
if curl -sf "$health_url" > /dev/null 2>&1; then
log_success "Container ${BG_INACTIVE_COLOR} is healthy"
break
fi
sleep 2
waited=$((waited + 2))
echo -n "."
done
echo ""
if [[ $waited -ge $max_wait ]]; then
log_error "Health check ${BG_INACTIVE_COLOR} failed après ${max_wait}s"
log_warning "Rollback: suppression ${BG_INACTIVE_COLOR}"
docker compose -f ".compose.prod.yml" down 2>/dev/null || true
docker rm -f "$new_container" 2>/dev/null || true
return 1
fi
# 3. Switch Nginx vers le nouveau port
log_step "3/5 Switch Nginx → ${BG_INACTIVE_COLOR}:${new_port}..."
if [[ -f "${SCRIPTS_DIR}/lib/nginx.sh" ]]; then
source "${SCRIPTS_DIR}/lib/nginx.sh"
local domain=$(resolve_domain)
if [[ -n "$domain" ]]; then
switch_nginx_upstream "$name" "$new_port" "$domain"
fi
fi
# 4. Drain : attendre que les connexions existantes terminent
log_step "4/5 Drain ${drain_seconds}s..."
sleep "$drain_seconds"
# 5. Stop l'ancien container
log_step "5/5 Stop ${BG_ACTIVE_COLOR} (${old_container})..."
docker stop "$old_container" 2>/dev/null || true
docker rm -f "$old_container" 2>/dev/null || true
# Aussi stopper le container sans suffixe (premier deploy, migration)
if [[ "$old_container" != "$name" ]]; then
docker stop "$name" 2>/dev/null || true
docker rm -f "$name" 2>/dev/null || true
fi
# Sauver le nouvel état
write_deploy_state "$BG_INACTIVE_COLOR" "$new_port" "$image"
# Cleanup
docker image prune -af --filter "until=24h" > /dev/null 2>&1 || true
docker network prune -f > /dev/null 2>&1 || true
# Status
docker ps --filter "name=${name}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
log_success "Blue-Green deployment terminé: ${BG_INACTIVE_COLOR}:${new_port} actif"
}
# ───────────────────────────────────────────────────────────────────────────
# BLUE-GREEN DEPLOYMENT - Remote (via SSH)
# ───────────────────────────────────────────────────────────────────────────
deploy_bluegreen_remote() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local health="${CFG_health:-/health}"
local drain_seconds="${CFG_deploy_drain_seconds:-10}"
local remote_path="${TARGET_PATH}/${name}"
# Lire l'état actuel
read_deploy_state
local new_container="${name}-${BG_INACTIVE_COLOR}"
local old_container="${name}-${BG_ACTIVE_COLOR}"
local new_port="$BG_INACTIVE_PORT"
local old_port="$BG_ACTIVE_PORT"
log_step "Blue-Green Deploy distant: ${BG_ACTIVE_COLOR}:${old_port} → ${BG_INACTIVE_COLOR}:${new_port} sur ${TARGET_HOST}"
# Créer répertoire distant
ssh_exec "$TARGET_HOST" "$TARGET_USER" "mkdir -p ${remote_path}"
# Docker login sur la machine distante
if [[ -n "${CI_JOB_TOKEN:-}" ]]; then
ssh_exec "$TARGET_HOST" "$TARGET_USER" "echo '${CI_JOB_TOKEN}' | docker login ${REGISTRY_URL} -u gitlab-ci-token --password-stdin"
fi
# 1. Générer et copier le compose pour le nouveau container
log_step "1/5 Démarrage ${BG_INACTIVE_COLOR} sur port ${new_port}..."
local orig_container_name="${CFG_container_name:-}"
CFG_container_name="$new_container"
generate_prod_compose "$image" "$new_container" "$new_port" "$health"
CFG_container_name="${orig_container_name:-}"
scp_file "${PROJECT_DIR}/.compose.prod.yml" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/.compose.${BG_INACTIVE_COLOR}.yml"
scp_file "${PROJECT_DIR}/.env.deploy" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker rm -f ${new_container} 2>/dev/null || true && docker compose -f .compose.${BG_INACTIVE_COLOR}.yml pull && docker compose -f .compose.${BG_INACTIVE_COLOR}.yml up -d"
# 2. Health check
log_step "2/5 Health check ${BG_INACTIVE_COLOR}..."
local health_url="http://${TARGET_HOST}:${new_port}${health}"
local max_wait=60
local waited=0
while [[ $waited -lt $max_wait ]]; do
if curl -sf "$health_url" > /dev/null 2>&1; then
log_success "Container ${BG_INACTIVE_COLOR} is healthy"
break
fi
sleep 2
waited=$((waited + 2))
done
if [[ $waited -ge $max_wait ]]; then
log_error "Health check ${BG_INACTIVE_COLOR} failed"
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker compose -f .compose.${BG_INACTIVE_COLOR}.yml down 2>/dev/null || true"
return 1
fi
# 3. Switch Nginx
log_step "3/5 Switch Nginx → ${BG_INACTIVE_COLOR}:${new_port}..."
if [[ -f "${SCRIPTS_DIR}/lib/nginx.sh" ]]; then
source "${SCRIPTS_DIR}/lib/nginx.sh"
local domain=$(resolve_domain)
if [[ -n "$domain" ]]; then
switch_nginx_upstream "$name" "$new_port" "$domain"
fi
fi
# 4. Drain
log_step "4/5 Drain ${drain_seconds}s..."
sleep "$drain_seconds"
# 5. Stop ancien container
log_step "5/5 Stop ${BG_ACTIVE_COLOR} (${old_container})..."
ssh_exec "$TARGET_HOST" "$TARGET_USER" "docker stop ${old_container} 2>/dev/null || true && docker rm -f ${old_container} 2>/dev/null || true"
# Aussi le container sans suffixe (migration)
if [[ "$old_container" != "$name" ]]; then
ssh_exec "$TARGET_HOST" "$TARGET_USER" "docker stop ${name} 2>/dev/null || true && docker rm -f ${name} 2>/dev/null || true"
fi
# Sauver l'état
write_deploy_state "$BG_INACTIVE_COLOR" "$new_port" "$image"
# Cleanup distant
ssh_exec "$TARGET_HOST" "$TARGET_USER" "docker image prune -af --filter 'until=24h' > /dev/null 2>&1 || true && docker network prune -f > /dev/null 2>&1 || true"
log_success "Blue-Green deployment distant terminé: ${BG_INACTIVE_COLOR}:${new_port} actif"
}
# ───────────────────────────────────────────────────────────────────────────
# DEPLOY ONLY - Déploiement sans build/push (stage: deploy)
# Dispatche vers la bonne stratégie
# ───────────────────────────────────────────────────────────────────────────
deploy_only_docker() {
local image_name="${CFG_name}"
local latest_image="${BUILT_IMAGE_LATEST:-${REGISTRY_URL}/gouroubleu/${image_name}:latest}"
local strategy="${CFG_deploy_strategy:-simple}"
log_info "Deploy only (image: ${latest_image}, stratégie: ${strategy})"
# Pre-deploy hooks
if type -t run_hooks &>/dev/null; then
run_hooks "pre_deploy" || return 1
fi
case "$strategy" in
blue-green|bluegreen)
if [[ "$TARGET_LOCAL" == "true" ]]; then
deploy_bluegreen_local "$latest_image"
else
deploy_bluegreen_remote "$latest_image"
fi
;;
zero-downtime|zdt)
if [[ "$TARGET_LOCAL" == "true" ]]; then
deploy_zero_downtime_local "$latest_image"
else
deploy_zero_downtime_remote "$latest_image"
fi
;;
simple|*)
if [[ "$TARGET_LOCAL" == "true" ]]; then
deploy_simple_local "$latest_image"
else
deploy_simple_remote "$latest_image"
fi
;;
esac
# Post-deploy hooks
if type -t run_hooks &>/dev/null; then
run_hooks "post_deploy" || true
fi
}
# ───────────────────────────────────────────────────────────────────────────
# SIMPLE DEPLOYMENT - Local (stop → start, quelques secondes de downtime)
# ───────────────────────────────────────────────────────────────────────────
deploy_simple_local() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local port="${CFG_port:-3000}"
local health="${CFG_health:-/health}"
log_step "Simple Deploy: ${name} sur port ${port}"
cd "$PROJECT_DIR"
# Générer le compose
generate_prod_compose "$image" "$name" "$port" "$health"
# Stop ancien stack proprement via compose down
docker compose -f ".compose.prod.yml" down --remove-orphans 2>/dev/null || true
# Fallback: rm -f si des containers trainent
docker rm -f "$name" 2>/dev/null || true
if [[ ${SIDECARS_COUNT:-0} -gt 0 ]]; then
for ((i=0; i<SIDECARS_COUNT; i++)); do
local sc_name_var="SIDECAR_${i}_name"
docker rm -f "${name}-${!sc_name_var}" 2>/dev/null || true
done
fi
# Pull et start
docker compose -f ".compose.prod.yml" pull || true
docker compose -f ".compose.prod.yml" up -d
# Cleanup
docker image prune -af --filter "until=24h" > /dev/null 2>&1 || true
# Status
docker ps --filter "name=${name}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
log_success "Simple deployment terminé"
}
# ───────────────────────────────────────────────────────────────────────────
# SIMPLE DEPLOYMENT - Remote
# ───────────────────────────────────────────────────────────────────────────
deploy_simple_remote() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local port="${CFG_port:-3000}"
local health="${CFG_health:-/health}"
local remote_path="${TARGET_PATH}/${name}"
log_step "Simple Deploy distant: ${name} → ${TARGET_HOST}"
# Créer répertoire distant
ssh_exec "$TARGET_HOST" "$TARGET_USER" "mkdir -p ${remote_path}"
# Générer et copier les fichiers
generate_prod_compose "$image" "$name" "$port" "$health"
scp_file "${PROJECT_DIR}/.compose.prod.yml" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
scp_file "${PROJECT_DIR}/.env.deploy" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
# Docker login sur la machine distante (CI_JOB_TOKEN disponible via env)
if [[ -n "${CI_JOB_TOKEN:-}" ]]; then
ssh_exec "$TARGET_HOST" "$TARGET_USER" "echo '${CI_JOB_TOKEN}' | docker login ${REGISTRY_URL} -u gitlab-ci-token --password-stdin"
fi
# Stop ancien stack proprement et deploy
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker compose -f .compose.prod.yml down --remove-orphans 2>/dev/null || true && docker rm -f ${name} 2>/dev/null || true && docker compose -f .compose.prod.yml pull && docker compose -f .compose.prod.yml up -d && docker image prune -af --filter 'until=24h' > /dev/null 2>&1 || true"
log_success "Simple deployment distant terminé"
}
# ───────────────────────────────────────────────────────────────────────────
# ZERO-DOWNTIME DEPLOYMENT - Local
# ───────────────────────────────────────────────────────────────────────────
deploy_zero_downtime_local() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local port="${CFG_port:-3000}"
local health="${CFG_health:-/health}"
local staging_port=$((port + STAGING_PORT_OFFSET))
log_step "Zero-Downtime Deploy: ${name}"
log_info "Port production: ${port}"
log_info "Port staging: ${staging_port}"
cd "$PROJECT_DIR"
# Vérifier si un container prod existe
local prod_exists=false
if docker ps --format '{{.Names}}' | grep -q "^${name}$"; then
prod_exists=true
log_info "Container production existant détecté"
fi
# 1. Démarrer le nouveau container sur le port staging
log_step "Démarrage container staging sur port ${staging_port}..."
local staging_container="${name}-staging"
# Supprimer un éventuel ancien staging
docker rm -f "$staging_container" 2>/dev/null || true
# Générer le compose pour staging
generate_staging_compose "$image" "$name" "$staging_container" "$staging_port" "$health"
docker compose -f ".compose.staging.yml" up -d
# 2. Health check sur staging
local docker_host="${TARGET_HOST:-172.17.0.1}"
local health_url="http://${docker_host}:${staging_port}${health}"
log_step "Health check: ${health_url}"
local max_wait=60
local waited=0
while [[ $waited -lt $max_wait ]]; do
if curl -sf "$health_url" > /dev/null 2>&1; then
log_success "Container staging is healthy"
break
fi
sleep 2
waited=$((waited + 2))
echo -n "."
done
echo ""
if [[ $waited -ge $max_wait ]]; then
log_error "Health check failed après ${max_wait}s"
log_warning "Rollback: suppression du staging"
docker compose -f ".compose.staging.yml" down
return 1
fi
# 3. SWAP ATOMIQUE
log_step "Swap atomique: staging → production"
# 3a. Stop le container prod (si existe)
if [[ "$prod_exists" == "true" ]]; then
log_info "Arrêt container production..."
docker stop "$name" 2>/dev/null || true
docker rm -f "$name" 2>/dev/null || true
fi
# 3b. Stop staging
docker stop "$staging_container" 2>/dev/null || true
docker rm -f "$staging_container" 2>/dev/null || true
# 3c. Démarrer la version prod (même image, port prod)
log_info "Démarrage container production sur port ${port}..."
generate_prod_compose "$image" "$name" "$port" "$health"
docker compose -f ".compose.prod.yml" up -d
# 4. Vérification finale
local prod_health_url="http://${docker_host}:${port}${health}"
sleep 2
if curl -sf "$prod_health_url" > /dev/null 2>&1; then
log_success "Container production healthy sur port ${port}"
else
log_warning "Container démarré mais health check en attente"
fi
# Cleanup
rm -f ".compose.staging.yml" 2>/dev/null
docker image prune -af --filter "until=24h" > /dev/null 2>&1 || true
# Status
docker ps --filter "name=${name}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
log_success "Zero-Downtime deployment terminé"
}
# ───────────────────────────────────────────────────────────────────────────
# Générer la commande healthcheck selon le type d'image
# ───────────────────────────────────────────────────────────────────────────
get_healthcheck_cmd() {
local port="$1"
local health="$2"
local type="${CFG_type:-node}"
case "$type" in
node|bun|qwik|next)
# Node.js disponible dans l'image
echo "test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http://localhost:${port}${health}', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\"]"
;;
*)
# Python, custom, etc. - utiliser curl
echo "test: [\"CMD-SHELL\", \"curl -sf http://localhost:${port}${health} || exit 1\"]"
;;
esac
}
# ───────────────────────────────────────────────────────────────────────────
# Générer compose pour staging (port temporaire)
# ───────────────────────────────────────────────────────────────────────────
generate_staging_compose() {
local image="$1"
local name="$2"
local container_name="$3"
local port="$4"
local health="$5"
local internal_port="${CFG_port:-3000}"
local healthcheck_cmd
healthcheck_cmd=$(get_healthcheck_cmd "${internal_port}" "${health}")
cat > "${PROJECT_DIR}/.compose.staging.yml" <<EOF
services:
${container_name}:
image: ${image}
container_name: ${container_name}
restart: "no"
ports:
- "${port}:${internal_port}"
environment:
- PORT=${internal_port}
env_file:
- .env.deploy
healthcheck:
${healthcheck_cmd}
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
EOF
# Ajouter volumes si configurés
if [[ -n "${CFG_volumes:-}" ]]; then
echo " volumes:" >> "${PROJECT_DIR}/.compose.staging.yml"
echo "${CFG_volumes}" | tr ',' '\n' | while read -r vol; do
[[ -n "$vol" ]] && echo " - ${vol}" >> "${PROJECT_DIR}/.compose.staging.yml"
done
# Ajouter volumes au niveau racine (pour les named volumes)
echo "" >> "${PROJECT_DIR}/.compose.staging.yml"
echo "volumes:" >> "${PROJECT_DIR}/.compose.staging.yml"
echo "${CFG_volumes}" | tr ',' '\n' | while read -r vol; do
local vol_name="${vol%%:*}"
if [[ -n "$vol_name" ]] && [[ "$vol" == *":"* ]]; then
echo " ${vol_name}:" >> "${PROJECT_DIR}/.compose.staging.yml"
fi
done
fi
}
# ───────────────────────────────────────────────────────────────────────────
# Append Docker options (cap_add, devices, sysctls, privileged) to compose
# Usage: append_docker_options <compose_file> <prefix>
# prefix: "CFG_docker" pour le main, "SIDECAR_N" pour les sidecars
# ───────────────────────────────────────────────────────────────────────────
append_docker_options() {
local compose_file="$1"
local prefix="$2"
# cap_add
local cap_var="${prefix}_cap_add"
if [[ -n "${!cap_var:-}" ]]; then
echo " cap_add:" >> "$compose_file"
echo "${!cap_var}" | tr ',' '\n' | while read -r cap; do
[[ -n "$cap" ]] && echo " - ${cap}" >> "$compose_file"
done
fi
# devices
local dev_var="${prefix}_devices"
if [[ -n "${!dev_var:-}" ]]; then
echo " devices:" >> "$compose_file"
echo "${!dev_var}" | tr ',' '\n' | while read -r dev; do
[[ -n "$dev" ]] && echo " - ${dev}" >> "$compose_file"
done
fi
# sysctls
local sys_var="${prefix}_sysctls"
if [[ -n "${!sys_var:-}" ]]; then
echo " sysctls:" >> "$compose_file"
echo "${!sys_var}" | tr ',' '\n' | while read -r sysctl; do
[[ -n "$sysctl" ]] && echo " - ${sysctl}" >> "$compose_file"
done
fi
# privileged
local priv_var="${prefix}_privileged"
if [[ "${!priv_var:-false}" == "true" ]]; then
echo " privileged: true" >> "$compose_file"
fi
}
# ───────────────────────────────────────────────────────────────────────────
# Générer compose pour production (port final)
# ───────────────────────────────────────────────────────────────────────────
generate_prod_compose() {
local image="$1"
local name="$2"
local port="$3"
local health="$4"
# Router vers la version multi-service si sidecars détectés
if [[ ${SIDECARS_COUNT:-0} -gt 0 ]]; then
generate_prod_compose_with_sidecars "$image" "$name" "$port" "$health"
return $?
fi
local internal_port="${CFG_port:-3000}"
local healthcheck_cmd
healthcheck_cmd=$(get_healthcheck_cmd "${internal_port}" "${health}")
# Si network_mode est défini (ex: container:wireguard-sidecar), on partage le namespace réseau
# Dans ce cas: pas de ports exposés, pas de network séparé
if [[ -n "${CFG_network_mode:-}" ]]; then
cat > "${PROJECT_DIR}/.compose.prod.yml" <<EOF
services:
${name}:
image: ${image}
container_name: ${name}
restart: unless-stopped
network_mode: "${CFG_network_mode}"
environment:
- PORT=${internal_port}
env_file:
- .env.deploy
labels:
- "env=${DEPLOY_ENV}"
- "service=${name}"
healthcheck:
${healthcheck_cmd}
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
EOF
else
cat > "${PROJECT_DIR}/.compose.prod.yml" <<EOF
services:
${name}:
image: ${image}
container_name: ${name}
restart: unless-stopped
ports:
- "${port}:${internal_port}"
environment:
- PORT=${internal_port}
env_file:
- .env.deploy
labels:
- "env=${DEPLOY_ENV}"
- "service=${name}"
healthcheck:
${healthcheck_cmd}
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
EOF
fi
# Ajouter volumes si configurés
if [[ -n "${CFG_volumes:-}" ]]; then
echo " volumes:" >> "${PROJECT_DIR}/.compose.prod.yml"
echo "${CFG_volumes}" | tr ',' '\n' | while read -r vol; do
[[ -n "$vol" ]] && echo " - ${vol}" >> "${PROJECT_DIR}/.compose.prod.yml"
done
fi
# Ajouter cap_add, devices, sysctls, privileged si configurés
append_docker_options "${PROJECT_DIR}/.compose.prod.yml" "CFG_docker"
# Ajouter network (sauf si network_mode est défini - incompatible)
if [[ -z "${CFG_network_mode:-}" ]]; then
local network="${CFG_network:-${name}-network}"
local network_external="${CFG_network_external:-false}"
if [[ "$network_external" == "true" ]]; then
cat >> "${PROJECT_DIR}/.compose.prod.yml" <<EOF
networks:
default:
name: ${network}
external: true
EOF
else
cat >> "${PROJECT_DIR}/.compose.prod.yml" <<EOF
networks:
default:
name: ${network}
EOF
fi
fi
# Ajouter volumes au niveau racine si configurés (pour les named volumes uniquement)
# Les bind mounts (commençant par /) ne doivent pas être ajoutés ici
if [[ -n "${CFG_volumes:-}" ]]; then
local has_named_volumes=false
echo "${CFG_volumes}" | tr ',' '\n' | while read -r vol; do
local vol_name="${vol%%:*}"
# Skip bind mounts (chemins absolus commençant par /)
if [[ -n "$vol_name" ]] && [[ "$vol" == *":"* ]] && [[ ! "$vol_name" =~ ^/ ]]; then
if [[ "$has_named_volumes" == "false" ]]; then
echo "" >> "${PROJECT_DIR}/.compose.prod.yml"
echo "volumes:" >> "${PROJECT_DIR}/.compose.prod.yml"
has_named_volumes=true
fi
echo " ${vol_name}:" >> "${PROJECT_DIR}/.compose.prod.yml"
fi
done
fi
}
# ───────────────────────────────────────────────────────────────────────────
# Générer compose multi-service avec sidecars
# ───────────────────────────────────────────────────────────────────────────
generate_prod_compose_with_sidecars() {
local image="$1"
local name="$2"
local port="$3"
local health="$4"
local internal_port="${CFG_port:-3000}"
local compose_file="${PROJECT_DIR}/.compose.prod.yml"
local healthcheck_cmd
healthcheck_cmd=$(get_healthcheck_cmd "${internal_port}" "${health}")
# Collecter les networks et volumes externes à déclarer
local external_networks=""
local named_volumes=""
# --- Début du fichier compose ---
cat > "$compose_file" <<EOF
services:
EOF
# --- Générer chaque sidecar ---
for ((i=0; i<SIDECARS_COUNT; i++)); do
local sc_name_var="SIDECAR_${i}_name"
local sc_image_var="SIDECAR_${i}_image"
local sc_health_var="SIDECAR_${i}_health"
local sc_name="${!sc_name_var}"
local sc_image="${!sc_image_var}"
local sc_container_name="${name}-${sc_name}"
cat >> "$compose_file" <<EOF
${sc_name}:
image: ${sc_image}
container_name: ${sc_container_name}
restart: unless-stopped
labels:
- "env=${DEPLOY_ENV}"
- "service=${name}"
- "sidecar=true"
EOF
# Ports du sidecar
local sc_ports_var="SIDECAR_${i}_ports"
if [[ -n "${!sc_ports_var:-}" ]]; then
echo " ports:" >> "$compose_file"
local IFS_BAK="$IFS"
IFS=','
for p in ${!sc_ports_var}; do
[[ -n "$p" ]] && echo " - \"${p}\"" >> "$compose_file"
done
IFS="$IFS_BAK"
fi
# cap_add, devices, sysctls, privileged
append_docker_options "$compose_file" "SIDECAR_${i}"
# Volumes du sidecar
local sc_volumes_var="SIDECAR_${i}_volumes"
if [[ -n "${!sc_volumes_var:-}" ]]; then
echo " volumes:" >> "$compose_file"
local IFS_BAK="$IFS"
IFS=','
for vol in ${!sc_volumes_var}; do
if [[ -n "$vol" ]]; then
echo " - ${vol}" >> "$compose_file"
local vol_name="${vol%%:*}"
if [[ "$vol" == *":"* ]] && [[ ! "$vol_name" =~ ^/ ]]; then
named_volumes="${named_volumes} ${vol_name}"
fi
fi
done
IFS="$IFS_BAK"
fi
# Networks du sidecar
local sc_networks_var="SIDECAR_${i}_networks"
if [[ -n "${!sc_networks_var:-}" ]]; then
echo " networks:" >> "$compose_file"
local IFS_BAK="$IFS"
IFS=','
for net in ${!sc_networks_var}; do
if [[ -n "$net" ]]; then
echo " - ${net}" >> "$compose_file"
external_networks="${external_networks} ${net}"
fi
done
IFS="$IFS_BAK"
fi
# Environnement du sidecar
local has_env=false
while IFS='=' read -r env_var env_val; do
local env_key="${env_var#SIDECAR_${i}_env_}"
if [[ "$env_var" == "SIDECAR_${i}_env_"* ]] && [[ -n "$env_key" ]]; then
if ! $has_env; then
echo " environment:" >> "$compose_file"
has_env=true
fi
echo " - ${env_key}=${env_val}" >> "$compose_file"
fi
done < <(env | grep "^SIDECAR_${i}_env_" | sort 2>/dev/null || true)
# Healthcheck du sidecar
if [[ -n "${!sc_health_var:-}" ]]; then
# Déterminer le port interne du sidecar pour le healthcheck
local sc_port=""
local sc_ports_var="SIDECAR_${i}_ports"
if [[ -n "${!sc_ports_var:-}" ]]; then
# Prendre le port interne du premier mapping
local first_port
first_port=$(echo "${!sc_ports_var}" | tr ',' '\n' | head -1)
sc_port="${first_port##*:}"
fi
if [[ -n "$sc_port" ]]; then
cat >> "$compose_file" <<EOF
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:${sc_port}${!sc_health_var}')\" || exit 1"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
EOF
fi
fi
echo "" >> "$compose_file"
done
# --- Service principal ---
cat >> "$compose_file" <<EOF
${name}:
image: ${image}
container_name: ${name}
restart: unless-stopped
EOF
# network_mode (service:sidecar-name)
if [[ -n "${CFG_network_mode:-}" ]]; then
echo " network_mode: \"${CFG_network_mode}\"" >> "$compose_file"
else
# Pas de network_mode → exposer les ports normalement
cat >> "$compose_file" <<EOF
ports:
- "${port}:${internal_port}"
EOF
fi
# depends_on sur les sidecars
echo " depends_on:" >> "$compose_file"
for ((i=0; i<SIDECARS_COUNT; i++)); do
local sc_name_var="SIDECAR_${i}_name"
local sc_health_var="SIDECAR_${i}_health"
if [[ -n "${!sc_health_var:-}" ]]; then
echo " ${!sc_name_var}:" >> "$compose_file"
echo " condition: service_healthy" >> "$compose_file"
else
echo " - ${!sc_name_var}" >> "$compose_file"
fi
done
cat >> "$compose_file" <<EOF
environment:
- PORT=${internal_port}
env_file:
- .env.deploy
labels:
- "env=${DEPLOY_ENV}"
- "service=${name}"
healthcheck:
${healthcheck_cmd}
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
EOF
# Volumes du main service
if [[ -n "${CFG_volumes:-}" ]]; then
echo " volumes:" >> "$compose_file"
local IFS_BAK="$IFS"
IFS=','
for vol in ${CFG_volumes}; do
[[ -n "$vol" ]] && echo " - ${vol}" >> "$compose_file"
done
IFS="$IFS_BAK"
fi
# cap_add, devices, sysctls du main
append_docker_options "$compose_file" "CFG_docker"
# --- Networks section ---
if [[ -n "$external_networks" ]]; then
echo "" >> "$compose_file"
echo "networks:" >> "$compose_file"
for net in $external_networks; do
cat >> "$compose_file" <<EOF
${net}:
external: true
EOF
done
fi
# --- Volumes section ---
# Collecter aussi les volumes du main service
if [[ -n "${CFG_volumes:-}" ]]; then
local IFS_BAK="$IFS"
IFS=','
for vol in ${CFG_volumes}; do
local vol_name="${vol%%:*}"
if [[ "$vol" == *":"* ]] && [[ ! "$vol_name" =~ ^/ ]]; then
named_volumes="${named_volumes} ${vol_name}"
fi
done
IFS="$IFS_BAK"
fi
if [[ -n "$named_volumes" ]]; then
echo "" >> "$compose_file"
echo "volumes:" >> "$compose_file"
# Dédupliquer
for vol_name in $(echo "$named_volumes" | tr ' ' '\n' | sort -u); do
[[ -n "$vol_name" ]] && echo " ${vol_name}:" >> "$compose_file"
done
fi
}
# ───────────────────────────────────────────────────────────────────────────
# ZERO-DOWNTIME DEPLOYMENT - Remote (via SSH)
# ───────────────────────────────────────────────────────────────────────────
deploy_zero_downtime_remote() {
local image="$1"
local name="${CFG_container_name:-${CFG_name}}"
local port="${CFG_port:-3000}"
local health="${CFG_health:-/health}"
local staging_port=$((port + STAGING_PORT_OFFSET))
local remote_path="${TARGET_PATH}/${name}"
log_step "Zero-Downtime Deploy distant: ${name} → ${TARGET_HOST}"
# Créer répertoire distant
ssh_exec "$TARGET_HOST" "$TARGET_USER" "mkdir -p ${remote_path}"
# Docker login sur la machine distante
if [[ -n "${CI_JOB_TOKEN:-}" ]]; then
ssh_exec "$TARGET_HOST" "$TARGET_USER" "echo '${CI_JOB_TOKEN}' | docker login ${REGISTRY_URL} -u gitlab-ci-token --password-stdin"
fi
# Générer et copier les fichiers
generate_staging_compose "$image" "$name" "${name}-staging" "$staging_port" "$health"
generate_prod_compose "$image" "$name" "$port" "$health"
scp_file "${PROJECT_DIR}/.compose.staging.yml" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
scp_file "${PROJECT_DIR}/.compose.prod.yml" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
scp_file "${PROJECT_DIR}/.env.deploy" "$TARGET_HOST" "$TARGET_USER" "${remote_path}/"
# 1. Démarrer staging
log_step "Démarrage staging sur port ${staging_port}..."
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker compose -f .compose.staging.yml pull && docker compose -f .compose.staging.yml up -d"
# 2. Health check
log_step "Health check staging..."
local health_url="http://${TARGET_HOST}:${staging_port}${health}"
local max_wait=60
local waited=0
while [[ $waited -lt $max_wait ]]; do
if curl -sf "$health_url" > /dev/null 2>&1; then
log_success "Staging healthy"
break
fi
sleep 2
waited=$((waited + 2))
done
if [[ $waited -ge $max_wait ]]; then
log_error "Health check failed"
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker compose -f .compose.staging.yml down"
return 1
fi
# 3. Swap
log_step "Swap atomique..."
ssh_exec "$TARGET_HOST" "$TARGET_USER" "cd ${remote_path} && docker stop ${name} 2>/dev/null || true && docker rm -f ${name} 2>/dev/null || true && docker compose -f .compose.staging.yml down && docker compose -f .compose.prod.yml up -d && docker image prune -af --filter 'until=24h' > /dev/null 2>&1 || true"
log_success "Déploiement distant terminé"
}
# ───────────────────────────────────────────────────────────────────────────
# Fonctions utilitaires
# ───────────────────────────────────────────────────────────────────────────
generate_dockerfile() {
local type="${CFG_type:-auto}"
[[ "$type" == "auto" ]] && type=$(detect_project_type)
log_info "Type: $type"
local template="${TEMPLATES_DIR}/Dockerfile.${type}"
if [[ -f "$template" ]]; then
cp "$template" "${PROJECT_DIR}/.Dockerfile.generated"
else
generate_generic_dockerfile "$type"
fi
}
detect_project_type() {
cd "$PROJECT_DIR"
[[ -f "Cargo.toml" ]] && echo "rust" && return
[[ -f "requirements.txt" ]] || [[ -f "pyproject.toml" ]] && echo "python" && return
[[ -f "go.mod" ]] && echo "go" && return
if [[ -f "package.json" ]]; then
grep -q "@builder.io/qwik" package.json 2>/dev/null && echo "qwik" && return
grep -q "\"next\"" package.json 2>/dev/null && echo "next" && return
[[ -f "bun.lockb" ]] && echo "bun" && return
echo "node" && return
fi
echo "node"
}
generate_generic_dockerfile() {
local type="$1"
local port="${CFG_port:-3000}"
case "$type" in
node)
cat > "${PROJECT_DIR}/.Dockerfile.generated" <<EOF
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ENV NODE_ENV=production PORT=${port}
EXPOSE ${port}
CMD ["node", "dist/index.js"]
EOF
;;
bun)
cat > "${PROJECT_DIR}/.Dockerfile.generated" <<EOF
FROM oven/bun:latest
WORKDIR /app
COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile --production
COPY . .
ENV NODE_ENV=production PORT=${port}
EXPOSE ${port}
CMD ["bun", "run", "start"]
EOF
;;
python)
cat > "${PROJECT_DIR}/.Dockerfile.generated" <<EOF
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1 PORT=${port}
EXPOSE ${port}
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "${port}"]
EOF
;;
esac
}