ci: IDC 서버 자동배포 파이프라인 구축
- Dockerfile: Turborepo 멀티스테이지 빌드 (Next.js standalone) - docker-compose.prod.yml: PostgreSQL/Redis/Nginx/Web/Admin 프로덕션 스택 - deploy/poll-deploy.sh: cron 기반 자동배포 (매분 Gitea 폴링) - deploy/nginx/default.conf: 리버스 프록시 설정 - next.config.ts: output standalone 추가 - .env.production.example: 환경변수 템플릿 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ===========================================
|
||||
# Re:Link Auto Deploy Script
|
||||
# Triggered by Gitea webhook on push to main
|
||||
# ===========================================
|
||||
|
||||
APP_DIR="$HOME/relink"
|
||||
LOG_FILE="$APP_DIR/deploy.log"
|
||||
LOCK_FILE="$APP_DIR/deploy.lock"
|
||||
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Prevent concurrent deploys
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||
if kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
log "ERROR: Deploy already running (PID: $LOCK_PID). Skipping."
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
echo $$ > "$LOCK_FILE"
|
||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||
|
||||
log "=== Deploy started ==="
|
||||
|
||||
# Navigate to app directory
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Clone or pull
|
||||
if [ ! -d "$APP_DIR/repo/.git" ]; then
|
||||
log "Cloning repository..."
|
||||
git clone "$GITEA_REPO" "$APP_DIR/repo"
|
||||
cd "$APP_DIR/repo"
|
||||
else
|
||||
cd "$APP_DIR/repo"
|
||||
log "Pulling latest changes..."
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
log "Current commit: $(git log --oneline -1)"
|
||||
|
||||
# Copy env file
|
||||
if [ -f "$APP_DIR/.env.production" ]; then
|
||||
cp "$APP_DIR/.env.production" "$APP_DIR/repo/.env.production"
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
log "Building and deploying with Docker Compose..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans 2>&1 | tee -a "$LOG_FILE"
|
||||
|
||||
# Wait for services
|
||||
log "Waiting for services to be healthy..."
|
||||
sleep 15
|
||||
|
||||
# Health check
|
||||
if curl -sf http://localhost/health > /dev/null 2>&1; then
|
||||
log "Health check PASSED"
|
||||
else
|
||||
log "WARNING: Health check failed - services may still be starting"
|
||||
fi
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f >> "$LOG_FILE" 2>&1
|
||||
|
||||
log "=== Deploy completed ==="
|
||||
@@ -0,0 +1,48 @@
|
||||
upstream web_app {
|
||||
server web:3000;
|
||||
}
|
||||
|
||||
upstream admin_app {
|
||||
server admin:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Web app (default)
|
||||
location / {
|
||||
proxy_pass http://web_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Admin app
|
||||
location /admin/ {
|
||||
rewrite ^/admin/(.*) /$1 break;
|
||||
proxy_pass http://admin_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# Re:Link Poll-based Auto Deploy
|
||||
# Checks Gitea for new commits every minute
|
||||
# ===========================================
|
||||
|
||||
APP_DIR="$HOME/relink"
|
||||
REPO_DIR="$APP_DIR/repo"
|
||||
LOG_FILE="$APP_DIR/deploy.log"
|
||||
LOCK_FILE="$APP_DIR/deploy.lock"
|
||||
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
|
||||
BRANCH="main"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; }
|
||||
|
||||
# Prevent concurrent deploys
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||
if kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
|
||||
# First time: clone
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
log "Initial clone..."
|
||||
echo $$ > "$LOCK_FILE"
|
||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||
git clone "$GITEA_REPO" "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
if [ -f "$APP_DIR/.env.production" ]; then
|
||||
cp "$APP_DIR/.env.production" "$REPO_DIR/.env.production"
|
||||
fi
|
||||
|
||||
log "Building and deploying (first time)..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker image prune -f >> "$LOG_FILE" 2>&1
|
||||
log "First deploy completed. Commit: $(git log --oneline -1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# Fetch latest
|
||||
git fetch origin "$BRANCH" --quiet 2>/dev/null
|
||||
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/$BRANCH)
|
||||
|
||||
# No changes
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# New commits detected - deploy
|
||||
echo $$ > "$LOCK_FILE"
|
||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||
|
||||
log "=== New commits detected ==="
|
||||
log "Local: $LOCAL"
|
||||
log "Remote: $REMOTE"
|
||||
|
||||
git reset --hard origin/$BRANCH
|
||||
log "Updated to: $(git log --oneline -1)"
|
||||
|
||||
if [ -f "$APP_DIR/.env.production" ]; then
|
||||
cp "$APP_DIR/.env.production" "$REPO_DIR/.env.production"
|
||||
fi
|
||||
|
||||
log "Building and deploying..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
|
||||
sleep 15
|
||||
if curl -sf http://localhost/health > /dev/null 2>&1; then
|
||||
log "Health check PASSED"
|
||||
else
|
||||
log "WARNING: Health check pending"
|
||||
fi
|
||||
|
||||
docker image prune -f >> "$LOG_FILE" 2>&1
|
||||
log "=== Deploy completed ==="
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ===========================================
|
||||
# Re:Link IDC Server Setup Script
|
||||
# Run once on the IDC server to initialize
|
||||
# ===========================================
|
||||
|
||||
echo "=== Re:Link Server Setup ==="
|
||||
|
||||
# 1. Install Docker Compose V2 plugin
|
||||
echo "[1/5] Installing Docker Compose V2 plugin..."
|
||||
DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
|
||||
mkdir -p "$DOCKER_CONFIG/cli-plugins"
|
||||
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
curl -SL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-x86_64" -o "$DOCKER_CONFIG/cli-plugins/docker-compose"
|
||||
chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose"
|
||||
echo "Docker Compose $(docker compose version) installed."
|
||||
|
||||
# 2. Install Gitea Act Runner
|
||||
echo "[2/5] Installing Gitea Act Runner..."
|
||||
ACT_RUNNER_VERSION="0.2.11"
|
||||
curl -SL "https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-amd64" -o /usr/local/bin/act_runner || {
|
||||
# Fallback: download to home dir if no sudo
|
||||
curl -SL "https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-amd64" -o "$HOME/act_runner"
|
||||
chmod +x "$HOME/act_runner"
|
||||
echo "act_runner installed to $HOME/act_runner"
|
||||
}
|
||||
chmod +x /usr/local/bin/act_runner 2>/dev/null || true
|
||||
echo "Act Runner v${ACT_RUNNER_VERSION} installed."
|
||||
|
||||
# 3. Create app directory
|
||||
echo "[3/5] Creating application directory..."
|
||||
APP_DIR="$HOME/relink"
|
||||
mkdir -p "$APP_DIR"
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 4. Generate runner config
|
||||
echo "[4/5] Generating runner config..."
|
||||
act_runner generate-config > "$APP_DIR/runner-config.yaml" 2>/dev/null || "$HOME/act_runner" generate-config > "$APP_DIR/runner-config.yaml" 2>/dev/null || true
|
||||
|
||||
# 5. Create systemd service for act_runner
|
||||
echo "[5/5] Creating systemd service..."
|
||||
RUNNER_BIN=$(which act_runner 2>/dev/null || echo "$HOME/act_runner")
|
||||
|
||||
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null << SERVICEEOF
|
||||
[Unit]
|
||||
Description=Gitea Act Runner
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$(whoami)
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${RUNNER_BIN} daemon --config ${APP_DIR}/runner-config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=DOCKER_HOST=unix:///var/run/docker.sock
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICEEOF
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Register the runner with Gitea:"
|
||||
echo " cd $APP_DIR"
|
||||
echo " act_runner register \\"
|
||||
echo " --instance http://39.117.244.52:3000 \\"
|
||||
echo " --token <REGISTRATION_TOKEN> \\"
|
||||
echo " --name relink-runner \\"
|
||||
echo " --labels ubuntu-latest:host"
|
||||
echo ""
|
||||
echo "2. Start the runner service:"
|
||||
echo " sudo systemctl daemon-reload"
|
||||
echo " sudo systemctl enable gitea-runner"
|
||||
echo " sudo systemctl start gitea-runner"
|
||||
echo ""
|
||||
echo "3. Create .env.production in $APP_DIR:"
|
||||
echo " cp .env.production.example .env.production"
|
||||
echo " # Edit with real values"
|
||||
echo ""
|
||||
echo "4. Add secrets in Gitea repository settings:"
|
||||
echo " - DB_USER, DB_PASSWORD, DB_NAME"
|
||||
echo " - REDIS_PASSWORD"
|
||||
echo " - NEXTAUTH_URL, NEXTAUTH_SECRET"
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re:Link Gitea Webhook Receiver
|
||||
Listens for push events and triggers deployment.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
|
||||
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "relink-deploy-secret")
|
||||
DEPLOY_SCRIPT = os.path.expanduser("~/relink/deploy.sh")
|
||||
DEPLOY_BRANCH = "main"
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
|
||||
LOG_FILE = os.path.expanduser("~/relink/webhook.log")
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||
print(line, flush=True)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def verify_signature(payload: bytes, signature: str) -> bool:
|
||||
if not signature:
|
||||
return False
|
||||
expected = hmac.new(
|
||||
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(f"sha256={expected}", signature)
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path != "/deploy":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
payload = self.rfile.read(content_length)
|
||||
|
||||
# Verify signature
|
||||
signature = self.headers.get("X-Gitea-Signature", "")
|
||||
if WEBHOOK_SECRET and not verify_signature(payload, signature):
|
||||
log("REJECTED: Invalid signature")
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Invalid signature")
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
ref = data.get("ref", "")
|
||||
if ref != f"refs/heads/{DEPLOY_BRANCH}":
|
||||
log(f"SKIPPED: Push to {ref} (not {DEPLOY_BRANCH})")
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Skipped: not target branch")
|
||||
return
|
||||
|
||||
pusher = data.get("pusher", {}).get("login", "unknown")
|
||||
commits = len(data.get("commits", []))
|
||||
log(f"DEPLOY TRIGGERED: {pusher} pushed {commits} commit(s) to {DEPLOY_BRANCH}")
|
||||
|
||||
# Run deploy script asynchronously
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["bash", DEPLOY_SCRIPT],
|
||||
stdout=open(LOG_FILE, "a"),
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Deploy started")
|
||||
log("Deploy script launched")
|
||||
except Exception as e:
|
||||
log(f"ERROR: Failed to start deploy: {e}")
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"Deploy failed: {e}".encode())
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress default logging
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log(f"Webhook receiver starting on port {PORT}")
|
||||
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Webhook receiver stopped")
|
||||
server.server_close()
|
||||
Reference in New Issue
Block a user