From 9b21af8892f3a6a13600d31b00c0667bfcbc911e Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 15:32:25 +0700 Subject: [PATCH] fix(deploy): require explicit environment and isolate containers per env Deploying staging from a folder checked out on main replaced the production container, because the environment was inferred from the current branch and both environments shared the container name "apskel-pos". - Environment is now a required argument (staging|production) - Refuse to deploy when the branch doesn't match, HEAD is detached, or the tree is dirty - Container name per environment; the legacy "apskel-pos" container is only removed by production deploys - Abort if the port is held by another environment's container - Confirmation prompt for production (--yes to skip) - Fast-forward-only pull from origin/ - Keep the previous image and roll back automatically when the new container is not healthy; --rollback for manual rollback Co-Authored-By: Claude Opus 5.5 --- deployment.sh | 169 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 142 insertions(+), 27 deletions(-) diff --git a/deployment.sh b/deployment.sh index f1fb342..7586274 100644 --- a/deployment.sh +++ b/deployment.sh @@ -1,62 +1,177 @@ #!/bin/bash set -euo pipefail +# Usage: +# ./deployment.sh staging deploy staging (branch staging, port 4001) +# ./deployment.sh production deploy production (branch main, port 4000) +# ./deployment.sh production --rollback kembali ke image sebelum deploy terakhir +# Tambahkan --yes untuk melewati konfirmasi production. + APP_NAME="apskel-pos" +LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment -# ─── Deteksi environment dari branch aktif ─────────────────────────────────── -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +usage() { + echo "Usage: $0 [--rollback] [--yes]" + exit 1 +} -case "$CURRENT_BRANCH" in - main) - ENV_MODE="production" +fail() { + echo "❌ $*" + exit 1 +} + +# ─── Argumen ────────────────────────────────────────────────────────────────── +ENV_MODE="${1:-}" +shift || true + +ROLLBACK=false +ASSUME_YES=false +for arg in "$@"; do + case "$arg" in + --rollback) ROLLBACK=true ;; + --yes|-y) ASSUME_YES=true ;; + *) usage ;; + esac +done + +# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif +case "$ENV_MODE" in + production) + EXPECTED_BRANCH="main" PORT="4000" ;; staging) - ENV_MODE="staging" + EXPECTED_BRANCH="staging" PORT="4001" ;; *) - echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment." - echo " Gunakan branch 'main' (production) atau 'staging' (staging)." - exit 1 + usage ;; esac -CONTAINER_NAME="$APP_NAME" +CONTAINER_NAME="$APP_NAME-$ENV_MODE" IMAGE_NAME="$APP_NAME:$ENV_MODE" +PREVIOUS_IMAGE="$APP_NAME:$ENV_MODE-previous" +CONFIG_FILE="infra/$ENV_MODE.yaml" + +# ─── Validasi repo ──────────────────────────────────────────────────────────── +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +if [ "$CURRENT_BRANCH" = "HEAD" ]; then + fail "Repo sedang detached HEAD. Jalankan 'git checkout $EXPECTED_BRANCH' dulu." +fi + +if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then + fail "Deploy $ENV_MODE harus dari branch '$EXPECTED_BRANCH', folder ini di branch '$CURRENT_BRANCH'. + Pakai folder terpisah per environment, jangan pindah branch di folder yang sedang dipakai container lain." +fi + +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + fail "Ada perubahan yang belum di-commit di folder ini. Bersihkan dulu sebelum deploy." +fi + +[ -f "$CONFIG_FILE" ] || fail "Config file '$CONFIG_FILE' tidak ditemukan." + +# ─── Pastikan port tidak dipakai container environment lain ────────────────── +OTHER_ON_PORT=$(docker ps --filter "publish=$PORT" --format '{{.Names}}' \ + | grep -vx "$CONTAINER_NAME" \ + | { if [ "$ENV_MODE" = "production" ]; then grep -vx "$LEGACY_CONTAINER"; else cat; fi; } \ + || true) +if [ -n "$OTHER_ON_PORT" ]; then + fail "Port $PORT sedang dipakai container lain: $OTHER_ON_PORT" +fi echo "📦 Environment : $ENV_MODE" echo "🌿 Branch : $CURRENT_BRANCH" echo "🐳 Container : $CONTAINER_NAME" echo "🔌 Port : $PORT" +echo "📁 Folder : $(pwd)" +[ "$ROLLBACK" = true ] && echo "⏪ Mode : ROLLBACK ke $PREVIOUS_IMAGE" echo "" -# ─── Pastikan config file ada ───────────────────────────────────────────────── -CONFIG_FILE="infra/$ENV_MODE.yaml" -if [ ! -f "$CONFIG_FILE" ]; then - echo "❌ Config file '$CONFIG_FILE' tidak ditemukan." - exit 1 +# ─── Konfirmasi production ──────────────────────────────────────────────────── +if [ "$ENV_MODE" = "production" ] && [ "$ASSUME_YES" != true ]; then + read -r -p "⚠️ Ini PRODUCTION. Ketik 'production' untuk lanjut: " CONFIRM + [ "$CONFIRM" = "production" ] || fail "Dibatalkan." fi -echo "🔄 Pulling latest code..." -git pull +run_container() { + local image="$1" + + echo "🛑 Stopping and removing old container..." + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + # Container production lama masih bernama 'apskel-pos'; staging tidak boleh menyentuhnya + if [ "$ENV_MODE" = "production" ]; then + docker rm -f "$LEGACY_CONTAINER" 2>/dev/null || true + fi + + echo "🚀 Running container from $image..." + docker run -d --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "$PORT:4000" \ + -e TZ=Asia/Jakarta \ + -e ENV_MODE="$ENV_MODE" \ + -v "$(pwd)/infra":/infra:ro \ + -v "$(pwd)/templates":/templates:ro \ + "$image" >/dev/null +} + +wait_healthy() { + echo "🩺 Waiting for healthcheck..." + for _ in $(seq 1 36); do + local status + status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing") + case "$status" in + healthy) return 0 ;; + unhealthy|exited|dead|missing) return 1 ;; + esac + sleep 5 + done + return 1 +} + +# ─── Rollback ───────────────────────────────────────────────────────────────── +if [ "$ROLLBACK" = true ]; then + docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1 || fail "Image '$PREVIOUS_IMAGE' tidak ada, tidak bisa rollback." + run_container "$PREVIOUS_IMAGE" + wait_healthy || fail "Container hasil rollback tidak healthy. Cek: docker logs $CONTAINER_NAME" + docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME" + echo "✅ Rollback $ENV_MODE complete." + exit 0 +fi + +# ─── Deploy ─────────────────────────────────────────────────────────────────── +echo "🔄 Pulling latest code (origin/$EXPECTED_BRANCH)..." +git fetch origin "$EXPECTED_BRANCH" +git merge --ff-only "origin/$EXPECTED_BRANCH" +echo " Commit: $(git log -1 --format='%h %s')" + +# Simpan image yang sedang jalan untuk rollback +if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + docker tag "$IMAGE_NAME" "$PREVIOUS_IMAGE" +fi echo "🐳 Building Docker image ($ENV_MODE)..." docker build --target production -t "$IMAGE_NAME" . -echo "🛑 Stopping and removing old container..." -docker rm -f "$CONTAINER_NAME" 2>/dev/null || true +run_container "$IMAGE_NAME" -echo "🚀 Running new container..." -docker run -d --name "$CONTAINER_NAME" \ - -p "$PORT:4000" \ - -e TZ=Asia/Jakarta \ - -e ENV_MODE="$ENV_MODE" \ - -v "$(pwd)/infra":/infra:ro \ - -v "$(pwd)/templates":/templates:ro \ - "$IMAGE_NAME" +if ! wait_healthy; then + echo "❌ Container baru tidak healthy. Log terakhir:" + docker logs --tail 50 "$CONTAINER_NAME" || true + if docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then + echo "⏪ Rolling back ke $PREVIOUS_IMAGE..." + run_container "$PREVIOUS_IMAGE" + wait_healthy || fail "Rollback juga tidak healthy. Cek manual: docker logs $CONTAINER_NAME" + # Jangan biarkan image rusak jadi 'previous' di deploy berikutnya + docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME" + fail "Deploy gagal, $ENV_MODE sudah dikembalikan ke image sebelumnya." + fi + fail "Deploy gagal dan tidak ada image sebelumnya untuk rollback." +fi echo "" echo "✅ Deployment $ENV_MODE complete." echo " Container : $CONTAINER_NAME" echo " Port : $PORT" +echo " Rollback : $0 $ENV_MODE --rollback"