- Create frontend/colors.mjs as SSOT for all design colors - Create scripts/generate-css-vars.mjs to auto-generate CSS variables - Update tailwind.config.ts to import colors from colors.mjs - Update frontend/package.json: add 'generate-css-vars' script to build pipeline - Update start_servers.py: include CSS variable generation before npm build - All color changes now require only one edit in colors.mjs - Build process auto-syncs CSS variables and Tailwind colors Eliminates DRY violation where colors were duplicated in: - tailwind.config.ts (Tailwind utility classes) - globals.css (CSS custom properties) Single source of truth approach ensures: ✓ No inconsistency between Tailwind and CSS variables ✓ One place to update colors for all contexts ✓ Automated CSS variable generation on every build Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
541 lines
19 KiB
Python
Executable File
541 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
TFM aInventory Standalone Server Launcher with SSL Support
|
|
Starts backend (FastAPI), frontend (Next.js), and Caddy reverse proxy
|
|
Full production-equivalent to Docker Compose deployment
|
|
|
|
Usage:
|
|
python3 start_servers.py # Start in foreground (interactive)
|
|
python3 start_servers.py start # Start in background
|
|
python3 start_servers.py stop # Stop background servers
|
|
python3 start_servers.py restart # Restart background servers
|
|
python3 start_servers.py status # Show server status
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import signal
|
|
import time
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
|
|
class StandaloneServerManager:
|
|
def __init__(self):
|
|
self.root_dir = Path(__file__).parent
|
|
self.pid_file = self.root_dir / ".servers.pid"
|
|
self.caddyfile = self.root_dir / "Caddyfile.standalone"
|
|
self.backend_process = None
|
|
self.frontend_process = None
|
|
self.caddy_process = None
|
|
self.load_config()
|
|
self.setup_directories()
|
|
|
|
def load_config(self):
|
|
"""Load configuration from inventory.env"""
|
|
env_file = self.root_dir / "inventory.env"
|
|
if not env_file.exists():
|
|
self.error(f"inventory.env not found at {env_file}")
|
|
|
|
self.config = {}
|
|
with open(env_file) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith('#') and '=' in line:
|
|
key, value = line.split('=', 1)
|
|
self.config[key.strip()] = value.strip().strip('"').strip("'")
|
|
|
|
self.backend_port = int(self.config.get('BACKEND_PORT', 8000))
|
|
self.frontend_port = int(self.config.get('FRONTEND_PORT', 3000))
|
|
self.backend_ssl_port = int(self.config.get('BACKEND_SSL_PORT', 8918))
|
|
self.frontend_ssl_port = int(self.config.get('FRONTEND_SSL_PORT', 8919))
|
|
self.data_dir = self.root_dir / self.config.get('DATA_DIR', 'data')
|
|
self.logs_dir = self.root_dir / self.config.get('LOGS_DIR', 'logs')
|
|
self.log_level = self.config.get('LOG_LEVEL', 'INFO').lower()
|
|
|
|
self.log(f"✓ Configuration loaded")
|
|
self.log(f" Backend HTTP: {self.backend_port}, HTTPS: {self.backend_ssl_port}")
|
|
self.log(f" Frontend HTTP: {self.frontend_port}, HTTPS: {self.frontend_ssl_port}")
|
|
|
|
def setup_directories(self):
|
|
"""Create necessary directories"""
|
|
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
(self.data_dir / "temp").mkdir(parents=True, exist_ok=True)
|
|
(self.logs_dir / "caddy").mkdir(parents=True, exist_ok=True)
|
|
self.log(f"✓ Directories ready")
|
|
|
|
def check_caddy(self):
|
|
"""Check if Caddy is installed"""
|
|
try:
|
|
subprocess.run(
|
|
["caddy", "version"],
|
|
capture_output=True,
|
|
check=True,
|
|
timeout=5
|
|
)
|
|
self.log("✓ Caddy is installed")
|
|
return True
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
self.log("⚠ Caddy not found - installing...")
|
|
return self.install_caddy()
|
|
|
|
def install_caddy(self):
|
|
"""Install Caddy using system package manager"""
|
|
try:
|
|
# Try apt (Debian/Ubuntu)
|
|
subprocess.run(
|
|
["apt-get", "update"],
|
|
capture_output=True,
|
|
timeout=60
|
|
)
|
|
subprocess.run(
|
|
["apt-get", "install", "-y", "caddy"],
|
|
capture_output=True,
|
|
timeout=120,
|
|
check=True
|
|
)
|
|
self.log("✓ Caddy installed successfully")
|
|
return True
|
|
except Exception as e:
|
|
self.log(f"⚠ Failed to install Caddy: {e}")
|
|
self.log(" Install Caddy manually: https://caddyserver.com/docs/install")
|
|
return False
|
|
|
|
def check_ports(self):
|
|
"""Check if all required ports are available"""
|
|
import socket
|
|
ports = [
|
|
(self.backend_port, "Backend HTTP"),
|
|
(self.frontend_port, "Frontend HTTP"),
|
|
(self.backend_ssl_port, "Backend HTTPS"),
|
|
(self.frontend_ssl_port, "Frontend HTTPS")
|
|
]
|
|
|
|
for port, name in ports:
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
result = sock.connect_ex(('127.0.0.1', port))
|
|
sock.close()
|
|
if result == 0:
|
|
self.error(f"Port {port} ({name}) is already in use")
|
|
except Exception as e:
|
|
self.log(f"⚠ Could not check port {port}: {e}")
|
|
|
|
self.log(f"✓ Required ports available")
|
|
|
|
def setup_backend(self):
|
|
"""Setup Python virtual environment and dependencies"""
|
|
venv_dir = self.root_dir / ".venv"
|
|
backend_dir = self.root_dir / "backend"
|
|
|
|
if not venv_dir.exists():
|
|
self.log("Creating Python virtual environment...")
|
|
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
|
|
|
|
pip_exe = venv_dir / "bin" / "pip"
|
|
python_exe = venv_dir / "bin" / "python"
|
|
requirements = backend_dir / "requirements.txt"
|
|
|
|
if not python_exe.exists():
|
|
self.error(f"Virtual environment failed - python not found at {python_exe}")
|
|
|
|
try:
|
|
subprocess.run(
|
|
[str(python_exe), "-c", "import fastapi"],
|
|
check=True,
|
|
capture_output=True
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
self.log("Installing Python dependencies...")
|
|
subprocess.run([str(pip_exe), "install", "-q", "-r", str(requirements)], check=True)
|
|
|
|
self.python_exe = python_exe
|
|
|
|
def setup_frontend(self):
|
|
"""Setup frontend dependencies"""
|
|
frontend_dir = self.root_dir / "frontend"
|
|
|
|
if not (frontend_dir / "node_modules").exists():
|
|
self.log("Installing npm dependencies...")
|
|
subprocess.run(
|
|
["npm", "ci", "--silent"],
|
|
cwd=str(frontend_dir),
|
|
check=True,
|
|
capture_output=True
|
|
)
|
|
|
|
if not (frontend_dir / ".next" / "standalone").exists():
|
|
# Generate CSS variables from colors.mjs before building
|
|
self.log("Generating CSS variables from design system...")
|
|
result = subprocess.run(
|
|
["node", "scripts/generate-css-vars.mjs"],
|
|
cwd=str(frontend_dir),
|
|
capture_output=True
|
|
)
|
|
if result.returncode != 0:
|
|
self.log(f"⚠ CSS variable generation warning: {result.stderr.decode()}")
|
|
|
|
self.log("Building Next.js application...")
|
|
result = subprocess.run(
|
|
["npm", "run", "build"],
|
|
cwd=str(frontend_dir),
|
|
capture_output=False
|
|
)
|
|
if result.returncode != 0:
|
|
self.error(f"Frontend build failed with exit code {result.returncode}")
|
|
|
|
# Copy static files to standalone directory (required for Next.js standalone mode)
|
|
static_src = frontend_dir / ".next" / "static"
|
|
static_dst = frontend_dir / ".next" / "standalone" / ".next" / "static"
|
|
if static_src.exists() and not static_dst.exists():
|
|
self.log("Copying static files to standalone build...")
|
|
import shutil
|
|
shutil.copytree(static_src, static_dst)
|
|
|
|
def start_backend(self):
|
|
"""Start FastAPI backend server"""
|
|
self.log(f"Starting backend on port {self.backend_port}...")
|
|
backend_log = self.logs_dir / "backend.log"
|
|
|
|
env = os.environ.copy()
|
|
env['PATH'] = f"{self.python_exe.parent}:{env['PATH']}"
|
|
|
|
with open(backend_log, 'w') as log_f:
|
|
self.backend_process = subprocess.Popen(
|
|
[
|
|
str(self.python_exe),
|
|
"-m", "uvicorn",
|
|
"backend.main:app",
|
|
"--host", "0.0.0.0",
|
|
"--port", str(self.backend_port),
|
|
"--log-level", self.log_level
|
|
],
|
|
cwd=str(self.root_dir),
|
|
stdout=log_f,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
preexec_fn=os.setsid
|
|
)
|
|
|
|
self.log(f"✓ Backend started (PID: {self.backend_process.pid})")
|
|
|
|
def start_frontend(self):
|
|
"""Start Next.js frontend server"""
|
|
self.log(f"Starting frontend on port {self.frontend_port}...")
|
|
frontend_log = self.logs_dir / "frontend.log"
|
|
frontend_dir = self.root_dir / "frontend"
|
|
|
|
env = os.environ.copy()
|
|
env['PORT'] = str(self.frontend_port)
|
|
env['NODE_ENV'] = 'production'
|
|
|
|
with open(frontend_log, 'w') as log_f:
|
|
self.frontend_process = subprocess.Popen(
|
|
["node", ".next/standalone/server.js"],
|
|
cwd=str(frontend_dir),
|
|
stdout=log_f,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
preexec_fn=os.setsid
|
|
)
|
|
|
|
self.log(f"✓ Frontend started (PID: {self.frontend_process.pid})")
|
|
|
|
def start_caddy(self):
|
|
"""Start Caddy reverse proxy with SSL"""
|
|
if not self.caddyfile.exists():
|
|
self.error(f"Caddyfile not found: {self.caddyfile}")
|
|
|
|
self.log(f"Starting Caddy reverse proxy...")
|
|
caddy_log = self.logs_dir / "caddy.log"
|
|
|
|
with open(caddy_log, 'w') as log_f:
|
|
self.caddy_process = subprocess.Popen(
|
|
[
|
|
"caddy", "run",
|
|
"--config", str(self.caddyfile),
|
|
"--adapter", "caddyfile"
|
|
],
|
|
stdout=log_f,
|
|
stderr=subprocess.STDOUT,
|
|
preexec_fn=os.setsid
|
|
)
|
|
|
|
self.log(f"✓ Caddy started (PID: {self.caddy_process.pid})")
|
|
|
|
def save_pids(self):
|
|
"""Save process IDs to file for stop/restart commands"""
|
|
pids = {
|
|
'backend': self.backend_process.pid if self.backend_process else None,
|
|
'frontend': self.frontend_process.pid if self.frontend_process else None,
|
|
'caddy': self.caddy_process.pid if self.caddy_process else None,
|
|
'timestamp': time.time()
|
|
}
|
|
with open(self.pid_file, 'w') as f:
|
|
json.dump(pids, f)
|
|
self.log(f"✓ Process IDs saved")
|
|
|
|
def load_pids(self):
|
|
"""Load process IDs from file"""
|
|
if not self.pid_file.exists():
|
|
return None
|
|
try:
|
|
with open(self.pid_file) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
def wait_for_startup(self):
|
|
"""Wait and check if servers started"""
|
|
time.sleep(3)
|
|
|
|
try:
|
|
import urllib.request
|
|
urllib.request.urlopen(f"http://localhost:{self.backend_port}/health", timeout=2)
|
|
self.log(f"✓ Backend responding")
|
|
except Exception:
|
|
self.log(f"⚠ Backend not responding yet")
|
|
|
|
try:
|
|
import urllib.request
|
|
urllib.request.urlopen(f"http://localhost:{self.frontend_port}/", timeout=2)
|
|
self.log(f"✓ Frontend responding")
|
|
except Exception:
|
|
self.log(f"⚠ Frontend not responding yet")
|
|
|
|
def print_summary(self):
|
|
"""Print summary of running servers"""
|
|
print("\n" + "="*70)
|
|
print(" ✓ TFM aInventory Standalone Deployment Started Successfully!")
|
|
print("="*70 + "\n")
|
|
print("Access points:")
|
|
print(f" Frontend HTTP: http://localhost:{self.frontend_port}")
|
|
print(f" Frontend HTTPS: https://localhost:{self.frontend_ssl_port} (self-signed)")
|
|
print(f" Backend HTTP: http://localhost:{self.backend_port}")
|
|
print(f" Backend HTTPS: https://localhost:{self.backend_ssl_port} (self-signed)")
|
|
print(f" API Docs: http://localhost:{self.backend_port}/docs")
|
|
print("\nProcess IDs:")
|
|
print(f" Backend: {self.backend_process.pid}")
|
|
print(f" Frontend: {self.frontend_process.pid}")
|
|
print(f" Caddy (SSL): {self.caddy_process.pid}")
|
|
print("\nLog files:")
|
|
print(f" {self.logs_dir / 'backend.log'}")
|
|
print(f" {self.logs_dir / 'frontend.log'}")
|
|
print(f" {self.logs_dir / 'caddy.log'}")
|
|
print("\nMonitoring:")
|
|
print(f" Status: python3 start_servers.py status")
|
|
print(f" Logs: tail -f {self.logs_dir}/*.log")
|
|
print("\nTo stop servers:")
|
|
print(f" python3 start_servers.py stop")
|
|
print("="*70 + "\n")
|
|
|
|
def run_foreground(self):
|
|
"""Run servers in foreground (interactive)"""
|
|
try:
|
|
print("\n=== TFM aInventory Standalone Deployment (Foreground) ===\n")
|
|
|
|
self.check_ports()
|
|
self.setup_backend()
|
|
self.setup_frontend()
|
|
if not self.check_caddy():
|
|
self.log("⚠ Caddy not available - HTTPS disabled")
|
|
|
|
self.start_backend()
|
|
self.start_frontend()
|
|
if self.caddy_process is None:
|
|
self.start_caddy()
|
|
|
|
self.wait_for_startup()
|
|
self.print_summary()
|
|
|
|
self.log("✓ All servers running! Press Ctrl+C to stop.")
|
|
while True:
|
|
if self.backend_process and self.backend_process.poll() is not None:
|
|
self.log(f"⚠ Backend exited (code {self.backend_process.returncode})")
|
|
|
|
if self.frontend_process and self.frontend_process.poll() is not None:
|
|
self.log(f"⚠ Frontend exited (code {self.frontend_process.returncode})")
|
|
|
|
if self.caddy_process and self.caddy_process.poll() is not None:
|
|
self.log(f"⚠ Caddy exited (code {self.caddy_process.returncode})")
|
|
|
|
time.sleep(5)
|
|
|
|
except KeyboardInterrupt:
|
|
self.log("Received interrupt signal, shutting down...")
|
|
self.cleanup()
|
|
except Exception as e:
|
|
self.error(str(e))
|
|
|
|
def run_background(self):
|
|
"""Run servers in background (detached)"""
|
|
try:
|
|
print("\n=== TFM aInventory Standalone Deployment (Background) ===\n")
|
|
|
|
self.check_ports()
|
|
self.setup_backend()
|
|
self.setup_frontend()
|
|
if not self.check_caddy():
|
|
self.log("⚠ Caddy not available - HTTPS disabled")
|
|
|
|
self.start_backend()
|
|
self.start_frontend()
|
|
if self.caddy_process is None:
|
|
self.start_caddy()
|
|
|
|
self.save_pids()
|
|
self.wait_for_startup()
|
|
self.print_summary()
|
|
|
|
print(f"✓ Servers running in background")
|
|
print(f" Stop: python3 start_servers.py stop")
|
|
print(f" Status: python3 start_servers.py status\n")
|
|
|
|
except Exception as e:
|
|
self.error(str(e))
|
|
|
|
def stop_servers(self):
|
|
"""Stop background servers"""
|
|
pids = self.load_pids()
|
|
if not pids:
|
|
print("[ERROR] No running servers found")
|
|
return False
|
|
|
|
success = True
|
|
|
|
for service, pid in [('Backend', pids.get('backend')),
|
|
('Frontend', pids.get('frontend')),
|
|
('Caddy', pids.get('caddy'))]:
|
|
if pid:
|
|
try:
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
print(f"[INFO] {service} stopped (PID {pid})")
|
|
time.sleep(1)
|
|
except Exception as e:
|
|
print(f"[WARN] Failed to stop {service}: {e}")
|
|
success = False
|
|
|
|
if self.pid_file.exists():
|
|
self.pid_file.unlink()
|
|
|
|
print("[INFO] ✓ Servers stopped")
|
|
return success
|
|
|
|
def show_status(self):
|
|
"""Show status of running servers"""
|
|
pids = self.load_pids()
|
|
if not pids:
|
|
print("[INFO] No running servers")
|
|
return
|
|
|
|
print("\n" + "="*70)
|
|
print(" Server Status")
|
|
print("="*70 + "\n")
|
|
|
|
for service, pid, port, ssl_port in [
|
|
('Backend', pids.get('backend'), self.backend_port, self.backend_ssl_port),
|
|
('Frontend', pids.get('frontend'), self.frontend_port, self.frontend_ssl_port),
|
|
('Caddy SSL', pids.get('caddy'), None, None)
|
|
]:
|
|
if pid:
|
|
try:
|
|
os.kill(pid, 0)
|
|
status = "✓ RUNNING"
|
|
if port:
|
|
print(f"{status}: {service} (PID {pid})")
|
|
print(f" HTTP: http://localhost:{port}")
|
|
print(f" HTTPS: https://localhost:{ssl_port}")
|
|
else:
|
|
print(f"{status}: {service} (PID {pid})")
|
|
except ProcessLookupError:
|
|
print(f"✗ STOPPED: {service} (was PID {pid})")
|
|
else:
|
|
print(f"✗ NOT FOUND: {service}")
|
|
|
|
print("\nLog files:")
|
|
print(f" {self.logs_dir / 'backend.log'}")
|
|
print(f" {self.logs_dir / 'frontend.log'}")
|
|
print(f" {self.logs_dir / 'caddy.log'}")
|
|
print("="*70 + "\n")
|
|
|
|
def cleanup(self):
|
|
"""Gracefully shutdown servers"""
|
|
for process in [self.backend_process, self.frontend_process, self.caddy_process]:
|
|
if process:
|
|
try:
|
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
process.wait(timeout=5)
|
|
except Exception:
|
|
try:
|
|
process.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
if self.pid_file.exists():
|
|
self.pid_file.unlink()
|
|
|
|
self.log("✓ Servers stopped")
|
|
sys.exit(0)
|
|
|
|
def log(self, message):
|
|
"""Print log message"""
|
|
print(f"[INFO] {message}")
|
|
|
|
def error(self, message):
|
|
"""Print error and exit"""
|
|
print(f"[ERROR] {message}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='TFM aInventory Standalone Deployment with SSL Support',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
python3 start_servers.py # Start in foreground (interactive, Ctrl+C to stop)
|
|
python3 start_servers.py start # Start in background
|
|
python3 start_servers.py stop # Stop background servers
|
|
python3 start_servers.py restart # Restart background servers
|
|
python3 start_servers.py status # Show server status
|
|
|
|
Features:
|
|
- Backend + Frontend + Caddy reverse proxy with SSL
|
|
- HTTP and HTTPS access
|
|
- Process management with PID file
|
|
- Self-signed certificates (development-friendly)
|
|
"""
|
|
)
|
|
parser.add_argument(
|
|
'command',
|
|
nargs='?',
|
|
choices=['start', 'stop', 'restart', 'status'],
|
|
default=None,
|
|
help='Command to execute (default: foreground mode)'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
manager = StandaloneServerManager()
|
|
|
|
if args.command == 'start':
|
|
manager.run_background()
|
|
elif args.command == 'stop':
|
|
manager.stop_servers()
|
|
elif args.command == 'restart':
|
|
manager.stop_servers()
|
|
time.sleep(2)
|
|
manager.run_background()
|
|
elif args.command == 'status':
|
|
manager.show_status()
|
|
elif args.command is None:
|
|
manager.run_foreground()
|
|
else:
|
|
manager.run_foreground()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|