33800 Docs

← 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 }