mirror of
https://github.com/thecyberlearn/hostinger-django-demo.git
synced 2026-08-18 06:52:56 +00:00
🔄 Complete transformation from mixed demo project to pure deployment toolkit REMOVED: ❌ Django demo project (core/, demo_project/, manage.py, requirements.txt) ❌ Demo-specific files and configurations ❌ Mixed-purpose confusion RESTRUCTURED: ✅ deploy-django-project.sh - Universal Django deployment ✅ setup-django-user.sh - VPS user setup ✅ setup-multi-webhook.sh - Auto-deploy webhooks ✅ webhook-router.py - Multi-project webhook handler ✅ templates/ - Configuration templates ✅ Clean root-level organization NEW PURPOSE: 🎯 Universal toolkit to deploy ANY Django project to VPS 🏷️ Auto-extracts GitHub repo names 🔄 Multi-project support with path-based routing 📡 GitHub webhook auto-deploy integration 🔒 Secure non-root deployment BENEFITS: - Deploy any Django project with one command - Zero configuration required - Professional deployment toolkit - Reusable for unlimited projects - Industry-standard VPS setup Now it's a PURE deployment toolkit, not a demo project! 🎉
350 lines
12 KiB
Python
Executable File
350 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Multi-Project GitHub Webhook Router
|
||
Routes webhook requests to appropriate Django projects based on repository name
|
||
Supports unlimited Django projects on single VPS
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import hmac
|
||
import hashlib
|
||
import subprocess
|
||
import logging
|
||
import glob
|
||
from datetime import datetime
|
||
from flask import Flask, request, jsonify
|
||
from threading import Thread
|
||
import time
|
||
|
||
# Configuration
|
||
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'your-webhook-secret-here')
|
||
PROJECTS_BASE_PATH = '/var/www'
|
||
ALLOWED_BRANCHES = ['main', 'master']
|
||
LOG_FILE = '/var/log/django/webhook-router.log'
|
||
|
||
# Setup logging
|
||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.FileHandler(LOG_FILE),
|
||
logging.StreamHandler()
|
||
]
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
app = Flask(__name__)
|
||
|
||
def discover_projects():
|
||
"""Discover all Django projects in /var/www"""
|
||
projects = {}
|
||
|
||
# Look for directories with manage.py (Django projects)
|
||
for project_dir in glob.glob(f"{PROJECTS_BASE_PATH}/*/"):
|
||
project_name = os.path.basename(project_dir.rstrip('/'))
|
||
|
||
# Skip hidden directories and system files
|
||
if project_name.startswith('.') or project_name == 'html':
|
||
continue
|
||
|
||
manage_py_path = os.path.join(project_dir, 'manage.py')
|
||
if os.path.exists(manage_py_path):
|
||
projects[project_name] = {
|
||
'path': project_dir.rstrip('/'),
|
||
'service': f'gunicorn-{project_name}.service'
|
||
}
|
||
|
||
logger.info(f"🔍 Discovered {len(projects)} Django projects: {list(projects.keys())}")
|
||
return projects
|
||
|
||
def extract_repo_name_from_url(repo_url):
|
||
"""Extract repository name from GitHub URL"""
|
||
# Examples:
|
||
# https://github.com/user/project-name.git -> project-name
|
||
# https://github.com/user/project-name -> project-name
|
||
# git@github.com:user/project-name.git -> project-name
|
||
|
||
import re
|
||
# Remove .git suffix and extract last part
|
||
repo_name = re.sub(r'\.git$', '', repo_url)
|
||
repo_name = repo_name.split('/')[-1]
|
||
return repo_name
|
||
|
||
def verify_signature(payload_body, signature_header):
|
||
"""Verify GitHub webhook signature"""
|
||
if not signature_header:
|
||
return False
|
||
|
||
hash_object = hmac.new(
|
||
WEBHOOK_SECRET.encode('utf-8'),
|
||
payload_body,
|
||
hashlib.sha256
|
||
)
|
||
expected_signature = "sha256=" + hash_object.hexdigest()
|
||
|
||
return hmac.compare_digest(expected_signature, signature_header)
|
||
|
||
def run_deployment(project_name, project_path, commit_info):
|
||
"""Execute deployment for specific project in background thread"""
|
||
try:
|
||
logger.info(f"🚀 Starting deployment for {project_name}...")
|
||
|
||
# Change to project directory
|
||
os.chdir(project_path)
|
||
|
||
# Store current commit for rollback
|
||
subprocess.run(['bash', '-c', f'echo "$(git rev-parse HEAD)" > /tmp/last_working_commit_{project_name}.txt'])
|
||
|
||
# Run deployment script
|
||
result = subprocess.run([
|
||
'sudo', '-u', 'django', 'bash', '-c',
|
||
f'''
|
||
cd {project_path}
|
||
|
||
# Pull latest changes
|
||
git fetch origin
|
||
git reset --hard origin/main
|
||
|
||
# Activate virtual environment and update
|
||
source venv/bin/activate
|
||
|
||
# Install/update dependencies
|
||
pip install -r requirements.txt
|
||
|
||
# Run Django management commands
|
||
python manage.py migrate
|
||
python manage.py collectstatic --noinput
|
||
|
||
# Test if Django can start (quick check)
|
||
python manage.py check --deploy
|
||
'''
|
||
], capture_output=True, text=True, timeout=300)
|
||
|
||
if result.returncode == 0:
|
||
# Restart project-specific service
|
||
service_name = f'gunicorn-{project_name}.service'
|
||
subprocess.run(['systemctl', 'restart', service_name], check=True)
|
||
|
||
# Wait and check if service is running
|
||
time.sleep(3)
|
||
service_check = subprocess.run(['systemctl', 'is-active', service_name],
|
||
capture_output=True, text=True)
|
||
|
||
if service_check.stdout.strip() == 'active':
|
||
logger.info(f"✅ Deployment successful for {project_name}!")
|
||
send_notification(f"✅ {project_name}: Deployment successful!", "success", commit_info)
|
||
else:
|
||
logger.error(f"❌ Service failed to start for {project_name}")
|
||
rollback(project_name, project_path)
|
||
else:
|
||
logger.error(f"❌ Deployment failed for {project_name}: {result.stderr}")
|
||
rollback(project_name, project_path)
|
||
|
||
except subprocess.TimeoutExpired:
|
||
logger.error(f"❌ Deployment timed out for {project_name}")
|
||
rollback(project_name, project_path)
|
||
except Exception as e:
|
||
logger.error(f"❌ Deployment error for {project_name}: {str(e)}")
|
||
rollback(project_name, project_path)
|
||
|
||
def rollback(project_name, project_path):
|
||
"""Rollback to previous working commit for specific project"""
|
||
try:
|
||
logger.info(f"🔄 Rolling back {project_name} to previous commit...")
|
||
|
||
rollback_file = f'/tmp/last_working_commit_{project_name}.txt'
|
||
if os.path.exists(rollback_file):
|
||
with open(rollback_file, 'r') as f:
|
||
last_commit = f.read().strip()
|
||
|
||
subprocess.run([
|
||
'sudo', '-u', 'django', 'bash', '-c',
|
||
f'cd {project_path} && git reset --hard {last_commit}'
|
||
], check=True)
|
||
|
||
service_name = f'gunicorn-{project_name}.service'
|
||
subprocess.run(['systemctl', 'restart', service_name], check=True)
|
||
logger.info(f"✅ Rollback completed for {project_name}")
|
||
send_notification(f"🔄 {project_name}: Rolled back due to deployment failure", "warning", {})
|
||
else:
|
||
logger.error(f"❌ No previous commit found for rollback: {project_name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Rollback failed for {project_name}: {str(e)}")
|
||
|
||
def send_notification(message, status="info", commit_info={}):
|
||
"""Send deployment notification"""
|
||
logger.info(f"📢 Notification: {message}")
|
||
|
||
# You can extend this to send notifications to:
|
||
# - Slack webhook
|
||
# - Discord webhook
|
||
# - Email
|
||
# - SMS
|
||
|
||
@app.route('/webhook/<project_name>', methods=['POST'])
|
||
def handle_project_webhook(project_name):
|
||
"""Handle GitHub webhook for specific project"""
|
||
|
||
# Verify signature
|
||
signature = request.headers.get('X-Hub-Signature-256')
|
||
if not verify_signature(request.data, signature):
|
||
logger.warning(f"❌ Invalid webhook signature for {project_name}")
|
||
return jsonify({"error": "Invalid signature"}), 403
|
||
|
||
# Parse payload
|
||
try:
|
||
payload = request.json
|
||
except:
|
||
logger.warning(f"❌ Invalid JSON payload for {project_name}")
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
|
||
# Check if it's a push event
|
||
if request.headers.get('X-GitHub-Event') != 'push':
|
||
logger.info(f"ℹ️ Ignoring non-push event for {project_name}: {request.headers.get('X-GitHub-Event')}")
|
||
return jsonify({"message": "Not a push event"}), 200
|
||
|
||
# Extract branch name
|
||
ref = payload.get('ref', '')
|
||
branch = ref.replace('refs/heads/', '')
|
||
|
||
# Check if it's a branch we care about
|
||
if branch not in ALLOWED_BRANCHES:
|
||
logger.info(f"ℹ️ Ignoring push to branch {branch} for {project_name}")
|
||
return jsonify({"message": f"Ignoring branch {branch}"}), 200
|
||
|
||
# Discover current projects
|
||
projects = discover_projects()
|
||
|
||
# Check if project exists
|
||
if project_name not in projects:
|
||
logger.warning(f"❌ Project not found: {project_name}")
|
||
return jsonify({"error": f"Project {project_name} not found"}), 404
|
||
|
||
# Get project details
|
||
project_path = projects[project_name]['path']
|
||
|
||
# Extract commit information
|
||
commit_hash = payload.get('after', 'unknown')
|
||
commit_message = ""
|
||
if payload.get('head_commit'):
|
||
commit_message = payload['head_commit'].get('message', '')
|
||
|
||
commit_info = {
|
||
'hash': commit_hash[:8],
|
||
'message': commit_message[:100],
|
||
'branch': branch
|
||
}
|
||
|
||
logger.info(f"🔔 Deployment triggered for {project_name}")
|
||
logger.info(f"📝 Branch: {branch}, Commit: {commit_hash[:8]} - {commit_message[:100]}")
|
||
|
||
# Start deployment in background thread
|
||
deployment_thread = Thread(target=run_deployment, args=(project_name, project_path, commit_info))
|
||
deployment_thread.start()
|
||
|
||
return jsonify({
|
||
"message": "Deployment started",
|
||
"project": project_name,
|
||
"branch": branch,
|
||
"commit": commit_hash[:8]
|
||
}), 200
|
||
|
||
@app.route('/webhook', methods=['POST'])
|
||
def handle_generic_webhook():
|
||
"""Handle generic webhook - try to determine project from repository URL"""
|
||
|
||
# Verify signature
|
||
signature = request.headers.get('X-Hub-Signature-256')
|
||
if not verify_signature(request.data, signature):
|
||
logger.warning("❌ Invalid webhook signature")
|
||
return jsonify({"error": "Invalid signature"}), 403
|
||
|
||
# Parse payload
|
||
try:
|
||
payload = request.json
|
||
except:
|
||
logger.warning("❌ Invalid JSON payload")
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
|
||
# Extract repository URL
|
||
repo_url = payload.get('repository', {}).get('clone_url', '')
|
||
if not repo_url:
|
||
repo_url = payload.get('repository', {}).get('html_url', '')
|
||
|
||
if not repo_url:
|
||
logger.warning("❌ No repository URL found in payload")
|
||
return jsonify({"error": "No repository URL found"}), 400
|
||
|
||
# Extract project name from repo URL
|
||
project_name = extract_repo_name_from_url(repo_url)
|
||
logger.info(f"🔍 Extracted project name: {project_name} from URL: {repo_url}")
|
||
|
||
# Redirect to project-specific webhook handler
|
||
return handle_project_webhook(project_name)
|
||
|
||
@app.route('/health', methods=['GET'])
|
||
def health_check():
|
||
"""Health check endpoint"""
|
||
projects = discover_projects()
|
||
return jsonify({
|
||
"status": "healthy",
|
||
"timestamp": datetime.now().isoformat(),
|
||
"projects_count": len(projects),
|
||
"projects": list(projects.keys())
|
||
})
|
||
|
||
@app.route('/status', methods=['GET'])
|
||
def deployment_status():
|
||
"""Get current deployment status for all projects"""
|
||
try:
|
||
projects = discover_projects()
|
||
status_data = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"projects": {}
|
||
}
|
||
|
||
for project_name, project_info in projects.items():
|
||
# Check service status
|
||
service_name = project_info['service']
|
||
service_check = subprocess.run(['systemctl', 'is-active', service_name],
|
||
capture_output=True, text=True)
|
||
|
||
# Get current commit
|
||
try:
|
||
os.chdir(project_info['path'])
|
||
commit_result = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||
capture_output=True, text=True)
|
||
current_commit = commit_result.stdout.strip()[:8] if commit_result.returncode == 0 else "unknown"
|
||
except:
|
||
current_commit = "unknown"
|
||
|
||
status_data["projects"][project_name] = {
|
||
"service_status": service_check.stdout.strip(),
|
||
"current_commit": current_commit,
|
||
"path": project_info['path'],
|
||
"service": service_name
|
||
}
|
||
|
||
return jsonify(status_data)
|
||
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
if __name__ == '__main__':
|
||
# Create log directory if it doesn't exist
|
||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||
|
||
logger.info("🚀 Starting Multi-Project GitHub Webhook Router...")
|
||
|
||
# Discover existing projects
|
||
projects = discover_projects()
|
||
logger.info(f"📁 Managing {len(projects)} Django projects")
|
||
for name, info in projects.items():
|
||
logger.info(f" - {name}: {info['path']}")
|
||
|
||
# Run Flask app
|
||
app.run(host='127.0.0.1', port=8001, debug=False) |