306 lines
9.7 KiB
Python
306 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
production.py — Manage the Bastebin Gunicorn process.
|
|
|
|
Usage:
|
|
python production.py start [--host HOST] [--port PORT] [--workers N]
|
|
python production.py stop
|
|
python production.py restart [--host HOST] [--port PORT] [--workers N]
|
|
python production.py status
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
PID_FILE = os.path.join(BASE_DIR, 'gunicorn.pid')
|
|
LOG_FILE = os.path.join(BASE_DIR, 'gunicorn.log')
|
|
CONF_FILE = os.path.join(BASE_DIR, 'gunicorn.conf.py')
|
|
VENV_BIN = os.path.join(BASE_DIR, '.venv', 'bin')
|
|
|
|
|
|
def _gunicorn_bin() -> str:
|
|
candidate = os.path.join(VENV_BIN, 'gunicorn')
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
import shutil
|
|
found = shutil.which('gunicorn')
|
|
if not found:
|
|
sys.exit('gunicorn not found — run python setup.py first.')
|
|
return found
|
|
|
|
|
|
def _read_pid() -> int | None:
|
|
try:
|
|
with open(PID_FILE) as f:
|
|
return int(f.read().strip())
|
|
except (FileNotFoundError, ValueError):
|
|
return None
|
|
|
|
|
|
def _process_alive(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except (ProcessLookupError, PermissionError):
|
|
return False
|
|
|
|
|
|
def _find_all_pids() -> list[int]:
|
|
"""Search 'ps' for all Gunicorn/Bastebin processes associated with this directory."""
|
|
pids = []
|
|
try:
|
|
# Use 'ps' to find processes related to this Gunicorn instance
|
|
out = subprocess.check_output(['ps', 'wax', '-o', 'pid,command'], text=True)
|
|
for line in out.splitlines():
|
|
# Match gunicorn running from this directory or with this config
|
|
if 'gunicorn' in line and (BASE_DIR in line or CONF_FILE in line):
|
|
if 'production.py' in line: continue
|
|
parts = line.strip().split()
|
|
if parts:
|
|
try:
|
|
pids.append(int(parts[0]))
|
|
except ValueError:
|
|
continue
|
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
pass
|
|
return sorted(list(set(pids)))
|
|
|
|
|
|
def _is_port_in_use(port: int) -> bool:
|
|
"""Check if the given port is already occupied."""
|
|
import socket
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(0.5)
|
|
return s.connect_ex(('127.0.0.1', port)) == 0
|
|
|
|
|
|
def cmd_status() -> None:
|
|
pid_file_val = _read_pid()
|
|
all_pids = _find_all_pids()
|
|
|
|
if pid_file_val and _process_alive(pid_file_val):
|
|
print(f'Running (Master PID {pid_file_val})')
|
|
if len(all_pids) > 1:
|
|
print(f'Workers ({len(all_pids) - 1} processes)')
|
|
elif all_pids:
|
|
print(f'Warning: Found {len(all_pids)} orphaned processes not tracked in {PID_FILE}:')
|
|
print(f' PIDs: {", ".join(map(str, all_pids))}')
|
|
print(' Action: Use "python production.py kill" to clean these up.')
|
|
else:
|
|
if pid_file_val:
|
|
try: os.remove(PID_FILE)
|
|
except OSError: pass
|
|
print('Stopped')
|
|
|
|
|
|
def cmd_start(host: str, port: int, workers: int) -> None:
|
|
pid = _read_pid()
|
|
all_pids = _find_all_pids()
|
|
|
|
if (pid and _process_alive(pid)) or all_pids:
|
|
print(f'Already running (Master PID {pid or all_pids[0]}). Use "restart" to reload.')
|
|
return
|
|
|
|
if _is_port_in_use(port):
|
|
sys.exit(f'Error: Port {port} is already in use by another program (possibly an old zombie instance).')
|
|
|
|
gunicorn = _gunicorn_bin()
|
|
|
|
log = open(LOG_FILE, 'a')
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[
|
|
gunicorn,
|
|
'--config', CONF_FILE,
|
|
'--bind', f'{host}:{port}',
|
|
'--workers', str(workers),
|
|
'--pid', PID_FILE,
|
|
'wsgi:app',
|
|
],
|
|
stdout=log,
|
|
stderr=log,
|
|
cwd=BASE_DIR,
|
|
start_new_session=True,
|
|
)
|
|
except Exception:
|
|
log.close()
|
|
raise
|
|
|
|
# Wait briefly to confirm the process stayed alive.
|
|
time.sleep(1.5)
|
|
log.close() # Gunicorn has inherited the fd; safe to close our end.
|
|
if proc.poll() is not None:
|
|
sys.exit(f'Gunicorn exited immediately. Check {LOG_FILE} for details.')
|
|
# Gunicorn writes its own PID file; confirm it appeared.
|
|
pid = _read_pid()
|
|
print(f'Started (PID {pid or proc.pid}) → http://{host}:{port}')
|
|
print(f'Logs: {LOG_FILE}')
|
|
|
|
|
|
def cmd_stop(graceful: bool = True, force: bool = False) -> bool:
|
|
pid = _read_pid()
|
|
all_pids = _find_all_pids()
|
|
|
|
if not pid or not _process_alive(pid):
|
|
if all_pids:
|
|
if force:
|
|
print(f"Cleaning up {len(all_pids)} orphaned processes...")
|
|
return cmd_kill()
|
|
print(f'Error: PID file is missing, but {len(all_pids)} orphaned processes were found.')
|
|
print('Use "python production.py kill" to force stop.')
|
|
else:
|
|
print('Not running.')
|
|
if pid:
|
|
try: os.remove(PID_FILE)
|
|
except OSError: pass
|
|
return False
|
|
|
|
# Standard shutdown
|
|
sig = signal.SIGTERM if graceful else signal.SIGKILL
|
|
os.kill(pid, sig)
|
|
|
|
# Wait up to 10 s for a clean shutdown.
|
|
print(f'Stopping PID {pid}...', end='', flush=True)
|
|
for _ in range(20):
|
|
time.sleep(0.5)
|
|
print('.', end='', flush=True)
|
|
if not _process_alive(pid):
|
|
break
|
|
print(' Done.')
|
|
|
|
if _process_alive(pid):
|
|
# Force-kill if still alive after graceful period.
|
|
os.kill(pid, signal.SIGKILL)
|
|
time.sleep(0.5)
|
|
|
|
if os.path.exists(PID_FILE):
|
|
try: os.remove(PID_FILE)
|
|
except OSError: pass
|
|
|
|
# Double check for ANY remaining processes in this directory
|
|
remaining = _find_all_pids()
|
|
if remaining and force:
|
|
for p in remaining:
|
|
try: os.kill(p, signal.SIGKILL)
|
|
except OSError: pass
|
|
|
|
return True
|
|
|
|
|
|
def cmd_kill() -> bool:
|
|
"""Nuke all Bastebin-related processes."""
|
|
pids = _find_all_pids()
|
|
if not pids:
|
|
print("Nothing to kill.")
|
|
if os.path.exists(PID_FILE):
|
|
os.remove(PID_FILE)
|
|
return False
|
|
|
|
print(f"Killing {len(pids)} processes...")
|
|
for pid in pids:
|
|
try:
|
|
print(f" - {pid}")
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
if os.path.exists(PID_FILE):
|
|
os.remove(PID_FILE)
|
|
print("Clean slate achieved.")
|
|
return True
|
|
|
|
|
|
def cmd_restart(host: str, port: int, workers: int) -> None:
|
|
pid = _read_pid()
|
|
if pid and _process_alive(pid):
|
|
# Send SIGHUP — Gunicorn performs a graceful reload without downtime.
|
|
os.kill(pid, signal.SIGHUP)
|
|
print(f'Reloaded (PID {pid})')
|
|
else:
|
|
print('Not running — starting fresh.')
|
|
cmd_start(host, port, workers)
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog='production.py',
|
|
description='Manage the Bastebin Gunicorn process.',
|
|
)
|
|
sub = parser.add_subparsers(dest='command', metavar='COMMAND')
|
|
sub.required = True
|
|
|
|
def _add_server_args(p: argparse.ArgumentParser) -> None:
|
|
p.add_argument('--host', default=None, metavar='HOST',
|
|
help='Bind address (default: 0.0.0.0)')
|
|
p.add_argument('--port', default=None, type=int, metavar='PORT',
|
|
help='Bind port (default: 5000)')
|
|
p.add_argument('--workers', default=None, type=int, metavar='N',
|
|
help='Worker processes (default: cpu_count + 1, min 2)')
|
|
|
|
p_start = sub.add_parser('start', help='Start Gunicorn in the background')
|
|
p_restart = sub.add_parser('restart', help='Gracefully reload (SIGHUP) or start if stopped')
|
|
p_stop = sub.add_parser('stop', help='Stop Gunicorn gracefully')
|
|
sub.add_parser('kill', help='Forcefully kill all Bastebin processes')
|
|
sub.add_parser('status', help='Show whether Gunicorn is running')
|
|
|
|
p_stop.add_argument('--force', action='store_true', help='Kill all orphaned processes')
|
|
_add_server_args(p_start)
|
|
_add_server_args(p_restart)
|
|
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
# 1. Load config for defaults
|
|
config_path = os.path.join(BASE_DIR, 'config.json')
|
|
config = {}
|
|
if os.path.exists(config_path):
|
|
try:
|
|
with open(config_path, 'r') as f:
|
|
config = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
sys.exit(f"Error: Failed to parse '{config_path}': {e}\nPlease check for syntax errors (like trailing commas).")
|
|
except IOError as e:
|
|
print(f"Warning: Could not read '{config_path}': {e}")
|
|
|
|
server_cfg = config.get('server', {})
|
|
default_host = server_cfg.get('host', '0.0.0.0')
|
|
default_port = server_cfg.get('port', 5000)
|
|
|
|
# 2. Parse arguments
|
|
parser = _build_parser()
|
|
args = parser.parse_args()
|
|
|
|
# 3. Resolve merge (CLI > config > absolute defaults)
|
|
default_workers = max(2, multiprocessing.cpu_count() + 1)
|
|
|
|
# Check if user provided CLI arguments.
|
|
# args.host and args.port always have values because of 'default' in ArgumentParser.
|
|
# We'll re-parse or check the sys.argv.
|
|
|
|
host = getattr(args, 'host', None) or default_host or '0.0.0.0'
|
|
port = getattr(args, 'port', None) or default_port or 5000
|
|
workers = getattr(args, 'workers', None) or default_workers
|
|
|
|
if args.command == 'start':
|
|
cmd_start(host, port, workers)
|
|
elif args.command == 'stop':
|
|
cmd_stop(force=args.force)
|
|
elif args.command == 'kill':
|
|
cmd_kill()
|
|
elif args.command == 'restart':
|
|
cmd_restart(host, port, workers)
|
|
elif args.command == 'status':
|
|
cmd_status()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|