Add Gitea webhook auto-deploy: webhook receiver plus server.js proxy route
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gitea Webhook Receiver for publish project auto-deploy.
|
||||
Receives POST from Gitea, runs git pull + docker-compose build.
|
||||
|
||||
Usage:
|
||||
python3 webhook_receiver.py [--port PORT] [--secret SECRET]
|
||||
[--repo-path PATH] [--compose-cmd CMD]
|
||||
|
||||
Run as systemd service on NAS host.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import threading
|
||||
import time
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger('webhook_receiver')
|
||||
|
||||
|
||||
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Handle incoming webhook POST requests from Gitea."""
|
||||
|
||||
# Disable logging for favicon
|
||||
def do_GET(self):
|
||||
if self.path == '/favicon.ico':
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == '/health':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'OK')
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != '/webhook':
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Validate secret
|
||||
secret = self.server.secret
|
||||
if secret:
|
||||
auth_header = self.headers.get('X-Gitea-Secret', '')
|
||||
if auth_header != secret:
|
||||
logger.warning('Unauthorized webhook attempt')
|
||||
self.send_response(401)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'error': 'Unauthorized'}).encode())
|
||||
return
|
||||
|
||||
# Read payload
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
payload = self.rfile.read(content_length).decode('utf-8')
|
||||
|
||||
try:
|
||||
data = json.loads(payload) if payload else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
|
||||
logger.info(f'Webhook received: {data.get("ref", "unknown")} from {self.client_address[0]}')
|
||||
|
||||
# Trigger deploy in background
|
||||
thread = threading.Thread(target=self._deploy, args=(data,))
|
||||
thread.start()
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'deploying'}).encode())
|
||||
|
||||
def _deploy(self, data):
|
||||
"""Run git pull and docker-compose up --build in the repo directory."""
|
||||
repo_path = self.server.repo_path
|
||||
compose_cmd = self.server.compose_cmd
|
||||
|
||||
logger.info(f'Starting deploy at {repo_path}')
|
||||
|
||||
# Build command
|
||||
if compose_cmd:
|
||||
cmd = f'cd "{repo_path}" && {compose_cmd}'
|
||||
else:
|
||||
# Default: git pull + npm install + vite build + docker-compose up --build -d
|
||||
cmd = f'''
|
||||
cd "{repo_path}" && git pull && npm install && npm run build && docker-compose -f docker-compose.yml up --build -d
|
||||
'''
|
||||
|
||||
try:
|
||||
# Run with shell
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600 # 10 min timeout
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info('Deploy completed successfully')
|
||||
logger.info(result.stdout[-500:] if result.stdout else '')
|
||||
else:
|
||||
logger.error(f'Deploy failed: {result.stderr[-500:] if result.stderr else "unknown error"}')
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error('Deploy timed out (>10 min)')
|
||||
except Exception as e:
|
||||
logger.error(f'Deploy error: {e}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Suppress default request logging
|
||||
pass
|
||||
|
||||
|
||||
class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
"""Threaded HTTP server for concurrent webhook handling."""
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, port, secret, repo_path, compose_cmd):
|
||||
super().__init__(('0.0.0.0', port), WebhookHandler)
|
||||
self.secret = secret or ''
|
||||
self.repo_path = repo_path
|
||||
self.compose_cmd = compose_cmd
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Gitea Webhook Auto-Deploy Receiver')
|
||||
parser.add_argument('--port', type=int, default=5000,
|
||||
help='Port to listen on (default: 5000)')
|
||||
parser.add_argument('--secret', type=str, default='',
|
||||
help='X-Gitea-Secret header value for authentication')
|
||||
parser.add_argument('--repo-path', type=str, default='/vol1/1000/docker/publish',
|
||||
help='Path to the git repository on NAS')
|
||||
parser.add_argument('--compose-cmd', type=str, default='',
|
||||
help='Custom deploy command (e.g., "docker-compose up --build -d")')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Auto-detect repo path from script location if not set
|
||||
if args.repo_path == '/vol1/1000/docker/publish':
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
args.repo_path = os.path.dirname(script_dir)
|
||||
|
||||
logger.info(f'Webhook receiver starting on port {args.port}')
|
||||
logger.info(f'Repository path: {args.repo_path}')
|
||||
if args.secret:
|
||||
logger.info('Secret authentication: ENABLED')
|
||||
else:
|
||||
logger.warning('No secret set - any POST to /webhook will trigger deploy!')
|
||||
|
||||
try:
|
||||
server = ThreadedHTTPServer(args.port, args.secret, args.repo_path, args.compose_cmd)
|
||||
logger.info(f'Listening on http://0.0.0.0:{args.port}/webhook')
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Shutting down...')
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user