diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5db5c3a..c3a6b30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -197,9 +197,11 @@ jobs: DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }} IMAGE_TAG: ${{ github.sha }} run: | - # Copy production compose and deploy + # Copy production compose, monitoring, and infra configs scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/" scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/" + scp -i ~/.ssh/deploy_key -r infra/pgbouncer/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/infra/pgbouncer/" + scp -i ~/.ssh/deploy_key -r scripts/backup/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/scripts/backup/" ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << DEPLOY_SCRIPT cd ~/goodgo @@ -218,12 +220,29 @@ jobs: docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services # Run database migrations - docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy + docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy # Cleanup old images docker image prune -f DEPLOY_SCRIPT + - name: Sync Nginx configs + env: + DEPLOY_HOST: ${{ secrets.STAGING_HOST }} + DEPLOY_USER: ${{ secrets.STAGING_USER }} + DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + scp -i ~/.ssh/deploy_key infra/nginx/*.conf \ + "$DEPLOY_USER@$DEPLOY_HOST:/tmp/goodgo-nginx/" + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'NGINX_SCRIPT' + sudo mkdir -p /tmp/goodgo-nginx + sudo cp /tmp/goodgo-nginx/*.conf /etc/nginx/sites-available/ 2>/dev/null || true + for conf in /etc/nginx/sites-available/*goodgo*; do + [ -f "$conf" ] && sudo ln -sf "$conf" /etc/nginx/sites-enabled/ + done + sudo nginx -t && sudo systemctl reload nginx + NGINX_SCRIPT + - name: Verify staging deployment env: STAGING_URL: ${{ secrets.STAGING_URL }} @@ -372,8 +391,11 @@ jobs: chmod 600 ~/.ssh/deploy_key ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + # Copy configs scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/" scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/" + scp -i ~/.ssh/deploy_key -r infra/pgbouncer/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/infra/pgbouncer/" + scp -i ~/.ssh/deploy_key -r scripts/backup/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/scripts/backup/" ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << DEPLOY_SCRIPT cd ~/goodgo @@ -389,11 +411,27 @@ jobs: docker compose -f docker-compose.prod.yml up -d --no-deps --wait web docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services - docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy + docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy docker image prune -f DEPLOY_SCRIPT + - name: Sync Nginx configs (production) + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_USER }} + DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + run: | + scp -i ~/.ssh/deploy_key infra/nginx/*.conf \ + "$DEPLOY_USER@$DEPLOY_HOST:/tmp/goodgo-nginx/" + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'NGINX_SCRIPT' + sudo cp /tmp/goodgo-nginx/*.conf /etc/nginx/sites-available/ 2>/dev/null || true + for conf in /etc/nginx/sites-available/*goodgo*; do + [ -f "$conf" ] && sudo ln -sf "$conf" /etc/nginx/sites-enabled/ + done + sudo nginx -t && sudo systemctl reload nginx + NGINX_SCRIPT + - name: Verify production deployment env: PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ee95b44..257a2d7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,7 +9,7 @@ services: container_name: goodgo-api restart: unless-stopped ports: - - '${API_PORT:-3001}:3001' + - '127.0.0.1:${API_PORT:-3001}:3001' environment: NODE_ENV: production DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME} @@ -66,7 +66,7 @@ services: container_name: goodgo-web restart: unless-stopped ports: - - '${WEB_PORT:-3000}:3000' + - '127.0.0.1:${WEB_PORT:-3000}:3000' environment: NODE_ENV: production NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://api:3001} @@ -479,7 +479,7 @@ services: container_name: goodgo-grafana restart: unless-stopped ports: - - '${GRAFANA_PORT:-3002}:3000' + - '127.0.0.1:${GRAFANA_PORT:-3002}:3000' environment: GF_SECURITY_ADMIN_USER__FILE: /run/secrets/grafana_admin_user GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password diff --git a/infra/cloudflare-dns.sh b/infra/cloudflare-dns.sh new file mode 100755 index 0000000..72a24b5 --- /dev/null +++ b/infra/cloudflare-dns.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# ============================================================================== +# GoodGo Platform — Cloudflare DNS Setup +# Adds DNS records for platform.goodgo.vn, api.goodgo.vn, grafana.goodgo.vn +# +# Prerequisites: +# export CF_API_TOKEN="your-cloudflare-api-token" +# export CF_ZONE_ID="your-goodgo-vn-zone-id" +# +# Usage: +# ./infra/cloudflare-dns.sh +# ./infra/cloudflare-dns.sh --dry-run # Preview without creating +# ./infra/cloudflare-dns.sh --delete # Remove records +# ============================================================================== + +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────────────────── +CF_API_TOKEN="${CF_API_TOKEN:?Error: Set CF_API_TOKEN environment variable}" +CF_ZONE_ID="${CF_ZONE_ID:?Error: Set CF_ZONE_ID environment variable}" +TARGET_IP="${TARGET_IP:-185.225.232.65}" +CF_API="https://api.cloudflare.com/client/v4" +DRY_RUN=false +DELETE=false + +# Parse flags +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --delete) DELETE=true ;; + esac +done + +# DNS records to manage +declare -a SUBDOMAINS=("platform" "api" "grafana") + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[DNS]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +info() { echo -e "${CYAN}[INFO]${NC} $*"; } + +# ── Helpers ─────────────────────────────────────────────────────────────────── +cf_api() { + local method="$1" + local endpoint="$2" + shift 2 + curl -s -X "$method" \ + "${CF_API}${endpoint}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "$@" +} + +get_record_id() { + local name="$1" + cf_api GET "/zones/${CF_ZONE_ID}/dns_records?type=A&name=${name}.goodgo.vn" \ + | jq -r '.result[0].id // empty' +} + +# ── Verify API Token ───────────────────────────────────────────────────────── +log "Verifying Cloudflare API token..." +VERIFY=$(cf_api GET "/user/tokens/verify") +if [ "$(echo "$VERIFY" | jq -r '.success')" != "true" ]; then + err "Invalid Cloudflare API token!" + echo "$VERIFY" | jq . + exit 1 +fi +log "API token verified." + +# ── Verify Zone ────────────────────────────────────────────────────────────── +ZONE_NAME=$(cf_api GET "/zones/${CF_ZONE_ID}" | jq -r '.result.name') +if [ "$ZONE_NAME" != "goodgo.vn" ]; then + err "Zone ID does not match goodgo.vn! Got: ${ZONE_NAME}" + exit 1 +fi +log "Zone verified: ${ZONE_NAME}" + +# ── Process DNS Records ───────────────────────────────────────────────────── +echo "" +log "==========================================" +if $DELETE; then + log " Deleting DNS records" +elif $DRY_RUN; then + log " DRY RUN — no changes will be made" +else + log " Creating/Updating DNS records" +fi +log " Target IP: ${TARGET_IP}" +log "==========================================" +echo "" + +for sub in "${SUBDOMAINS[@]}"; do + FQDN="${sub}.goodgo.vn" + EXISTING_ID=$(get_record_id "$sub") + + if $DELETE; then + if [ -n "$EXISTING_ID" ]; then + if $DRY_RUN; then + info "[DRY RUN] Would delete: ${FQDN} (ID: ${EXISTING_ID})" + else + RESULT=$(cf_api DELETE "/zones/${CF_ZONE_ID}/dns_records/${EXISTING_ID}") + if [ "$(echo "$RESULT" | jq -r '.success')" = "true" ]; then + log "Deleted: ${FQDN}" + else + err "Failed to delete ${FQDN}: $(echo "$RESULT" | jq -r '.errors[0].message')" + fi + fi + else + warn "Record not found: ${FQDN} — skipping delete" + fi + continue + fi + + RECORD_DATA=$(cat <&2; } + +# ── Pre-flight checks ──────────────────────────────────────────────────────── +if [ "$(id -u)" -ne 0 ]; then + err "This script must be run as root (sudo)." + exit 1 +fi + +DEPLOY_USER="${DEPLOY_USER:-ubuntu}" +DEPLOY_DIR="/home/${DEPLOY_USER}/goodgo" + +log "Starting GoodGo Platform server setup..." +log "Deploy user: ${DEPLOY_USER}" +log "Deploy dir: ${DEPLOY_DIR}" + +# ── 1. System Updates ───────────────────────────────────────────────────────── +log "Updating system packages..." +apt-get update -qq +apt-get upgrade -y -qq + +# ── 2. Install Essential Packages ───────────────────────────────────────────── +log "Installing essential packages..." +apt-get install -y -qq \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + software-properties-common \ + git \ + jq \ + htop \ + unzip \ + fail2ban \ + ufw \ + logrotate + +# ── 3. Install Docker Engine ───────────────────────────────────────────────── +if command -v docker &>/dev/null; then + log "Docker already installed: $(docker --version)" +else + log "Installing Docker Engine..." + curl -fsSL https://get.docker.com | sh + + # Add deploy user to docker group + usermod -aG docker "${DEPLOY_USER}" + log "Docker installed: $(docker --version)" +fi + +# Ensure Docker starts on boot +systemctl enable docker +systemctl start docker + +# ── 4. Install Docker Compose v2 (plugin) ──────────────────────────────────── +if docker compose version &>/dev/null; then + log "Docker Compose already installed: $(docker compose version --short)" +else + log "Installing Docker Compose v2 plugin..." + DOCKER_COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | jq -r .tag_name) + mkdir -p /usr/local/lib/docker/cli-plugins + curl -fsSL "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + log "Docker Compose installed: $(docker compose version --short)" +fi + +# ── 5. Install Nginx ────────────────────────────────────────────────────────── +if command -v nginx &>/dev/null; then + log "Nginx already installed: $(nginx -v 2>&1)" +else + log "Installing Nginx..." + apt-get install -y -qq nginx + log "Nginx installed: $(nginx -v 2>&1)" +fi + +systemctl enable nginx +systemctl start nginx + +# ── 6. Configure Firewall (ufw) ────────────────────────────────────────────── +log "Configuring firewall (ufw)..." +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' +ufw allow 80/tcp comment 'HTTP — redirect to HTTPS' +ufw allow 443/tcp comment 'HTTPS' +ufw --force enable +log "Firewall configured. Active rules:" +ufw status verbose + +# ── 7. Configure fail2ban ───────────────────────────────────────────────────── +log "Configuring fail2ban..." +cat > /etc/fail2ban/jail.local << 'F2B_CONF' +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 5 + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +maxretry = 3 + +[nginx-http-auth] +enabled = true + +[nginx-botsearch] +enabled = true +F2B_CONF + +systemctl enable fail2ban +systemctl restart fail2ban +log "fail2ban configured and running." + +# ── 8. Create Directory Structure ──────────────────────────────────────────── +log "Creating deployment directory structure..." +mkdir -p "${DEPLOY_DIR}"/{monitoring/{prometheus,grafana/{provisioning,dashboards},loki,promtail,alertmanager},scripts/backup,infra/pgbouncer} +chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_DIR}" +log "Directory structure created at ${DEPLOY_DIR}" + +# ── 9. Create SSL Directory ────────────────────────────────────────────────── +log "Creating SSL certificate directory..." +mkdir -p /etc/ssl/goodgo +chmod 700 /etc/ssl/goodgo +log "SSL directory: /etc/ssl/goodgo (place origin.pem + origin-key.pem here)" + +# ── 10. Nginx Base Configuration ───────────────────────────────────────────── +log "Creating Nginx base configuration..." + +# Create sites-available/sites-enabled structure if not exists +mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled + +# Check if nginx.conf already includes sites-enabled +if ! grep -q 'sites-enabled' /etc/nginx/nginx.conf; then + warn "Adding 'include sites-enabled' to nginx.conf" + sed -i '/http {/a \ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf +fi + +# Remove default site +rm -f /etc/nginx/sites-enabled/default + +# Nginx performance tuning +cat > /etc/nginx/conf.d/performance.conf << 'NGINX_PERF' +# GoodGo Platform — Nginx Performance Tuning +# Rate limiting zone (used by API vhost) +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; + +# Gzip compression +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + +# Proxy settings +proxy_connect_timeout 60s; +proxy_send_timeout 60s; +proxy_read_timeout 60s; +proxy_buffers 32 8k; + +# Security +server_tokens off; +NGINX_PERF + +nginx -t && systemctl reload nginx +log "Nginx base configuration ready." + +# ── 11. Docker Log Rotation ────────────────────────────────────────────────── +log "Configuring Docker log rotation..." +mkdir -p /etc/docker +cat > /etc/docker/daemon.json << 'DOCKER_LOG' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "5" + }, + "storage-driver": "overlay2" +} +DOCKER_LOG + +systemctl restart docker +log "Docker log rotation configured." + +# ── 12. Swap (if <4GB RAM) ─────────────────────────────────────────────────── +TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') +if [ "$TOTAL_MEM" -lt 4096 ]; then + if [ ! -f /swapfile ]; then + log "Low RAM (${TOTAL_MEM}MB). Creating 4GB swap..." + fallocate -l 4G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + log "Swap created and enabled." + else + log "Swap already exists." + fi +else + log "RAM: ${TOTAL_MEM}MB — swap not needed." +fi + +# ── 13. Kernel Tuning (for Docker/PostgreSQL) ──────────────────────────────── +log "Applying kernel tuning..." +cat >> /etc/sysctl.conf << 'SYSCTL' + +# GoodGo — Docker + PostgreSQL tuning +vm.overcommit_memory = 1 +vm.swappiness = 10 +net.core.somaxconn = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.ipv4.ip_local_port_range = 1024 65535 +fs.file-max = 2097152 +SYSCTL + +sysctl -p >/dev/null 2>&1 || true +log "Kernel parameters tuned." + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +log "==========================================" +log " Server setup complete!" +log "==========================================" +log "" +log " Next steps:" +log " 1. Place Cloudflare Origin Certificate:" +log " /etc/ssl/goodgo/origin.pem" +log " /etc/ssl/goodgo/origin-key.pem" +log "" +log " 2. Copy Nginx vhost configs to:" +log " /etc/nginx/sites-available/" +log " Then symlink to /etc/nginx/sites-enabled/" +log "" +log " 3. Create .env file at:" +log " ${DEPLOY_DIR}/.env" +log "" +log " 4. Login to GHCR:" +log " docker login ghcr.io" +log "" +log " 5. Deploy:" +log " cd ${DEPLOY_DIR}" +log " docker compose -f docker-compose.prod.yml up -d" +log "==========================================" diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 0000000..f1a2aa0 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# ============================================================================== +# GoodGo Platform — Manual Production Deploy Script +# Backup for CI/CD pipeline. Use when GitHub Actions is unavailable. +# +# Usage (from the server): +# cd ~/goodgo +# ./deploy-production.sh [image-tag] +# +# Usage (from local machine): +# ssh ubuntu@185.225.232.65 'cd ~/goodgo && ./deploy-production.sh abc1234' +# ============================================================================== + +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────────────────── +COMPOSE_FILE="docker-compose.prod.yml" +IMAGE_TAG="${1:-latest}" +HEALTH_URL="http://127.0.0.1:3001/health" +HEALTH_RETRIES=15 +HEALTH_INTERVAL=5 +ROLLBACK_ON_FAIL=true + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[DEPLOY]${NC} $(date +%H:%M:%S) $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $(date +%H:%M:%S) $*"; } +err() { echo -e "${RED}[ERROR]${NC} $(date +%H:%M:%S) $*" >&2; } +info() { echo -e "${CYAN}[INFO]${NC} $(date +%H:%M:%S) $*"; } + +# ── Pre-flight Checks ──────────────────────────────────────────────────────── +if [ ! -f "$COMPOSE_FILE" ]; then + err "Compose file not found: $COMPOSE_FILE" + err "Are you in the ~/goodgo directory?" + exit 1 +fi + +if [ ! -f ".env" ]; then + err ".env file not found. Copy from infra/env.production.example" + exit 1 +fi + +log "==========================================" +log " GoodGo Platform — Production Deploy" +log " Image tag: ${IMAGE_TAG}" +log " Compose: ${COMPOSE_FILE}" +log "==========================================" +echo "" + +# ── Step 1: Record Current State (for rollback) ────────────────────────────── +log "Step 1/6: Recording current state for rollback..." +PREV_API=$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none") +PREV_WEB=$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none") +PREV_AI=$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none") +info "Previous API: ${PREV_API}" +info "Previous Web: ${PREV_WEB}" +info "Previous AI: ${PREV_AI}" + +# ── Step 2: Pull New Images ────────────────────────────────────────────────── +log "Step 2/6: Pulling new images (tag: ${IMAGE_TAG})..." +export IMAGE_TAG +docker compose -f "$COMPOSE_FILE" pull api web ai-services +log "Images pulled successfully." + +# ── Step 3: Rolling Update ─────────────────────────────────────────────────── +log "Step 3/6: Rolling update (zero-downtime)..." + +info "Updating API..." +docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait api +info "API updated and healthy." + +info "Updating Web..." +docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait web +info "Web updated and healthy." + +info "Updating AI Services..." +docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait ai-services +info "AI Services updated and healthy." + +log "Rolling update complete." + +# ── Step 4: Database Migrations ────────────────────────────────────────────── +log "Step 4/6: Running database migrations..." +docker compose -f "$COMPOSE_FILE" exec -T api npx prisma migrate deploy +log "Migrations complete." + +# ── Step 5: Health Check Verification ──────────────────────────────────────── +log "Step 5/6: Verifying deployment health..." +HEALTHY=false +for i in $(seq 1 "$HEALTH_RETRIES"); do + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + HEALTHY=true + break + fi + info "Waiting for health check... (${i}/${HEALTH_RETRIES})" + sleep "$HEALTH_INTERVAL" +done + +if $HEALTHY; then + log "Health check passed!" +else + err "Health check failed after ${HEALTH_RETRIES} attempts!" + + if $ROLLBACK_ON_FAIL; then + warn "Initiating rollback..." + + # Rollback: stop current, docker compose will use previously cached images + docker compose -f "$COMPOSE_FILE" stop api web ai-services + docker compose -f "$COMPOSE_FILE" up -d --wait api web ai-services + + warn "Rollback complete. Verifying..." + sleep 5 + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + warn "Services recovered after rollback." + else + err "CRITICAL: Services still unhealthy after rollback!" + err "Manual intervention required." + fi + fi + + exit 1 +fi + +# ── Step 6: Cleanup ────────────────────────────────────────────────────────── +log "Step 6/6: Cleaning up old images..." +docker image prune -f +log "Cleanup complete." + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +log "==========================================" +log " Deployment successful!" +log "==========================================" +log "" +log " Services:" +info " API: $(docker inspect --format='{{.Config.Image}}' goodgo-api)" +info " Web: $(docker inspect --format='{{.Config.Image}}' goodgo-web)" +info " AI: $(docker inspect --format='{{.Config.Image}}' goodgo-ai-services)" +log "" +log " Endpoints:" +info " Web: https://platform.goodgo.vn" +info " API: https://api.goodgo.vn" +info " Grafana: https://grafana.goodgo.vn" +log "" +log " Run smoke tests:" +info " ./scripts/smoke-test.sh https://api.goodgo.vn" +log "=========================================="