- Script was using /usr/bin/python3 which doesn't have uvicorn installed - Now detects and uses venv/bin/python3 if available - Backend now starts correctly with: 'python3 scripts/run_standalone.py start|restart' - All services (Backend, Frontend, SSL Proxy) now start properly
472 lines
17 KiB
Python
Executable File
472 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
TFM aInventory - Standalone Launcher (v1.12.0)
|
|
Converted from run_standalone.sh to Python per Decision D-05.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import yaml
|
|
import argparse
|
|
import subprocess
|
|
import signal
|
|
import time
|
|
import threading
|
|
import logging
|
|
import socket
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
# Color codes
|
|
class Colors:
|
|
RED = '\033[0;31m'
|
|
GREEN = '\033[0;32m'
|
|
YELLOW = '\033[1;33m'
|
|
BLUE = '\033[0;34m'
|
|
MAGENTA = '\033[0;35m'
|
|
CYAN = '\033[0;36m'
|
|
NC = '\033[0m'
|
|
|
|
# Setup logging
|
|
logging.basicConfig(level=logging.INFO, format=f"{Colors.BLUE}[INFO]{Colors.NC} %(message)s")
|
|
logger = logging.getLogger("run_standalone")
|
|
|
|
processes: List[subprocess.Popen] = []
|
|
shutdown_event = threading.Event()
|
|
|
|
def signal_handler(sig, frame):
|
|
logger.info("Shutdown signal received. Stopping processes...")
|
|
shutdown_event.set()
|
|
for p in processes:
|
|
try:
|
|
p.terminate()
|
|
except:
|
|
pass
|
|
# Give them a moment to terminate gracefully
|
|
time.sleep(1)
|
|
for p in processes:
|
|
try:
|
|
p.kill()
|
|
except:
|
|
pass
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
def load_yaml(file_path: str) -> Dict[str, Any]:
|
|
if not os.path.exists(file_path):
|
|
logger.warning(f"Config file {file_path} not found. Using defaults.")
|
|
return {}
|
|
try:
|
|
with open(file_path, 'r') as f:
|
|
return yaml.safe_load(f) or {}
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse {file_path}: {e}")
|
|
return {}
|
|
|
|
# PID file for tracking processes
|
|
PID_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".standalone.pid")
|
|
|
|
def get_running_pids() -> List[int]:
|
|
if not os.path.exists(PID_FILE):
|
|
return []
|
|
try:
|
|
with open(PID_FILE, 'r') as f:
|
|
return [int(line.strip()) for line in f if line.strip()]
|
|
except:
|
|
return []
|
|
|
|
def save_pids(pids: List[int]):
|
|
with open(PID_FILE, 'w') as f:
|
|
for pid in pids:
|
|
f.write(f"{pid}\n")
|
|
|
|
def clear_pids():
|
|
if os.path.exists(PID_FILE):
|
|
try:
|
|
os.remove(PID_FILE)
|
|
except:
|
|
pass
|
|
|
|
def is_pid_running(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
def is_port_in_use(port: int) -> bool:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
return s.connect_ex(('localhost', port)) == 0
|
|
|
|
def log_stream(stream, prefix, color):
|
|
for line in iter(stream.readline, ''):
|
|
if shutdown_event.is_set():
|
|
break
|
|
if line:
|
|
print(f"{color}[{prefix}]{Colors.NC} {line.strip()}")
|
|
stream.close()
|
|
|
|
def start_backend(backend_port: int, backend_cfg: Dict[str, Any], background: bool = False):
|
|
logger.info(f"Starting Backend on port {backend_port}...")
|
|
|
|
app_cfg = backend_cfg.get("application", {})
|
|
env = os.environ.copy()
|
|
env["DATA_DIR"] = app_cfg.get("data_dir", "./data")
|
|
env["LOGS_DIR"] = app_cfg.get("logs_dir", "./logs")
|
|
env["LOG_LEVEL"] = backend_cfg.get("logging", {}).get("log_level", "INFO")
|
|
|
|
# Ensure directories exist
|
|
os.makedirs(env["DATA_DIR"], exist_ok=True)
|
|
os.makedirs(env["LOGS_DIR"], exist_ok=True)
|
|
|
|
# Use venv python if available
|
|
python_exe = sys.executable
|
|
venv_python = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "venv", "bin", "python3")
|
|
if os.path.exists(venv_python):
|
|
python_exe = venv_python
|
|
|
|
cmd = [
|
|
python_exe, "-m", "uvicorn",
|
|
"backend.main:app",
|
|
"--host", "0.0.0.0",
|
|
"--port", str(backend_port)
|
|
]
|
|
|
|
log_file = None
|
|
if background:
|
|
log_path = os.path.join(env["LOGS_DIR"], "backend_standalone.log")
|
|
log_file = open(log_path, "a")
|
|
logger.info(f"Backend logs redirected to {log_path}")
|
|
|
|
try:
|
|
p = subprocess.Popen(
|
|
cmd,
|
|
stdout=log_file if background else subprocess.PIPE,
|
|
stderr=log_file if background else subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
start_new_session=background
|
|
)
|
|
processes.append(p)
|
|
|
|
if not background:
|
|
threading.Thread(target=log_stream, args=(p.stdout, "BACKEND", Colors.GREEN), daemon=True).start()
|
|
threading.Thread(target=log_stream, args=(p.stderr, "BACKEND", Colors.GREEN), daemon=True).start()
|
|
|
|
return p
|
|
except FileNotFoundError:
|
|
logger.error("uvicorn not found. Please install it with: pip install uvicorn")
|
|
sys.exit(1)
|
|
|
|
def start_frontend(frontend_port: int, frontend_cfg: Dict[str, Any], network_cfg: Dict[str, Any], background: bool = False):
|
|
logger.info(f"Starting Frontend on port {frontend_port}...")
|
|
|
|
server_ip = network_cfg.get("application", {}).get("server_ip", "localhost")
|
|
ssl_enabled = network_cfg.get("ssl", {}).get("ssl_enabled", False)
|
|
ports = network_cfg.get("ports", {})
|
|
|
|
# Dynamically construct API URL from master settings
|
|
if ssl_enabled:
|
|
back_ssl_port = ports.get("backend_ssl_port", 8918)
|
|
api_url = f"https://{server_ip}:{back_ssl_port}"
|
|
else:
|
|
back_port = ports.get("backend_port", 8916)
|
|
api_url = f"http://{server_ip}:{back_port}"
|
|
|
|
logger.info(f"Dynamically injecting API URL: {api_url}")
|
|
|
|
env = os.environ.copy()
|
|
env["NEXT_PUBLIC_API_URL"] = api_url
|
|
env["PORT"] = str(frontend_port)
|
|
|
|
cmd = ["npm", "run", "dev"]
|
|
if os.path.exists("frontend/server.js"):
|
|
cmd = ["node", "frontend/server.js"]
|
|
|
|
log_file = None
|
|
if background:
|
|
logs_dir = os.environ.get("LOGS_DIR", "./logs")
|
|
log_path = os.path.join(logs_dir, "frontend_standalone.log")
|
|
log_file = open(log_path, "a")
|
|
logger.info(f"Frontend logs redirected to {log_path}")
|
|
|
|
try:
|
|
p = subprocess.Popen(
|
|
cmd,
|
|
cwd="frontend" if os.path.isdir("frontend") else None,
|
|
stdout=log_file if background else subprocess.PIPE,
|
|
stderr=log_file if background else subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
start_new_session=background
|
|
)
|
|
processes.append(p)
|
|
|
|
if not background:
|
|
threading.Thread(target=log_stream, args=(p.stdout, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
|
threading.Thread(target=log_stream, args=(p.stderr, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
|
|
|
return p
|
|
except FileNotFoundError:
|
|
logger.error("npm or node not found. Please install Node.js")
|
|
sys.exit(1)
|
|
|
|
def start_proxy(network_cfg: Dict[str, Any], background: bool = False):
|
|
ssl_cfg = network_cfg.get("ssl", {})
|
|
if not ssl_cfg.get("ssl_enabled", False):
|
|
return None
|
|
|
|
caddyfile = "Caddyfile.standalone"
|
|
if not os.path.exists(caddyfile):
|
|
logger.warning(f"{caddyfile} not found. SSL proxy will not start.")
|
|
return None
|
|
|
|
logger.info("Starting SSL Proxy (Caddy)...")
|
|
|
|
# Check if caddy is installed
|
|
try:
|
|
subprocess.run(["caddy", "version"], capture_output=True, check=True)
|
|
except:
|
|
logger.error("Caddy not found. Please install Caddy to use SSL proxy.")
|
|
return None
|
|
|
|
cmd = ["caddy", "run", "--config", caddyfile, "--adapter", "caddyfile"]
|
|
|
|
log_file = None
|
|
if background:
|
|
logs_dir = os.environ.get("LOGS_DIR", "./logs")
|
|
log_path = os.path.join(logs_dir, "proxy_standalone.log")
|
|
log_file = open(log_path, "a")
|
|
logger.info(f"Proxy logs redirected to {log_path}")
|
|
|
|
try:
|
|
p = subprocess.Popen(
|
|
cmd,
|
|
stdout=log_file if background else subprocess.PIPE,
|
|
stderr=log_file if background else subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
start_new_session=background
|
|
)
|
|
processes.append(p)
|
|
|
|
if not background:
|
|
threading.Thread(target=log_stream, args=(p.stdout, "PROXY", Colors.CYAN), daemon=True).start()
|
|
threading.Thread(target=log_stream, args=(p.stderr, "PROXY", Colors.CYAN), daemon=True).start()
|
|
|
|
return p
|
|
except Exception as e:
|
|
logger.error(f"Failed to start Caddy: {e}")
|
|
return None
|
|
|
|
def action_status(backend_port: int, frontend_port: int, network_cfg: Dict[str, Any]):
|
|
logger.info("Checking status...")
|
|
backend_up = is_port_in_use(backend_port)
|
|
frontend_up = is_port_in_use(frontend_port)
|
|
|
|
ssl_cfg = network_cfg.get("ssl", {})
|
|
ssl_enabled = ssl_cfg.get("ssl_enabled", False)
|
|
proxy_up = False
|
|
if ssl_enabled:
|
|
proxy_up = is_port_in_use(network_cfg.get("ports", {}).get("frontend_ssl_port", 8919))
|
|
|
|
pids = get_running_pids()
|
|
running_pids = [pid for pid in pids if is_pid_running(pid)]
|
|
|
|
if backend_up or frontend_up or running_pids:
|
|
print(f"{Colors.GREEN}● aInventory Standalone is RUNNING{Colors.NC}")
|
|
print(f" - Backend (Port {backend_port}): {'UP' if backend_up else 'DOWN'}")
|
|
print(f" - Frontend (Port {frontend_port}): {'UP' if frontend_up else 'DOWN'}")
|
|
if ssl_enabled:
|
|
print(f" - SSL Proxy: {'UP' if proxy_up else 'DOWN'}")
|
|
if running_pids:
|
|
print(f" - Active PIDs: {', '.join(map(str, running_pids))}")
|
|
return True
|
|
else:
|
|
print(f"{Colors.RED}○ aInventory Standalone is STOPPED{Colors.NC}")
|
|
return False
|
|
|
|
def action_stop(backend_port: int, frontend_port: int, network_cfg: Dict[str, Any]):
|
|
pids = get_running_pids()
|
|
|
|
if pids:
|
|
logger.info(f"Stopping processes with PIDs: {', '.join(map(str, pids))}...")
|
|
for pid in pids:
|
|
if is_pid_running(pid):
|
|
try:
|
|
# Kill the whole process group if possible
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
except:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
|
|
time.sleep(2)
|
|
|
|
for pid in pids:
|
|
if is_pid_running(pid):
|
|
try:
|
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
except:
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
clear_pids()
|
|
|
|
# Port-based cleanup (handles orphaned children like npm -> node)
|
|
cleanup_ports = [backend_port, frontend_port]
|
|
ports_cfg = network_cfg.get("ports", {})
|
|
if network_cfg.get("ssl", {}).get("ssl_enabled"):
|
|
cleanup_ports.append(ports_cfg.get("backend_ssl_port", 8918))
|
|
cleanup_ports.append(ports_cfg.get("frontend_ssl_port", 8919))
|
|
|
|
for port in cleanup_ports:
|
|
if is_port_in_use(port):
|
|
logger.info(f"Cleaning up orphaned process on port {port}...")
|
|
try:
|
|
# Use fuser-like logic to find the PID on that port
|
|
# Note: This is Linux specific
|
|
cmd = ["lsof", "-t", f"-i:{port}"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
orphans = [int(p) for p in result.stdout.splitlines() if p.strip()]
|
|
for o_pid in orphans:
|
|
if o_pid != os.getpid():
|
|
logger.info(f" - Killing orphan PID {o_pid}")
|
|
try:
|
|
os.kill(o_pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"Could not perform port-based cleanup: {e}")
|
|
|
|
logger.info("Stopped.")
|
|
|
|
def generate_network_json(network_cfg: Dict[str, Any]):
|
|
"""Sync network.yaml settings to frontend/public/network.json for runtime discovery."""
|
|
server_ip = network_cfg.get("application", {}).get("server_ip", "localhost")
|
|
ports = network_cfg.get("ports", {})
|
|
|
|
network_json = {
|
|
"SERVER_IP": server_ip,
|
|
"BACKEND_PORT": ports.get("backend_port", 8916),
|
|
"BACKEND_SSL_PORT": ports.get("backend_ssl_port", 8918),
|
|
"FRONTEND_PORT": ports.get("frontend_port", 8917),
|
|
"FRONTEND_SSL_PORT": ports.get("frontend_ssl_port", 8919)
|
|
}
|
|
|
|
# Try to find the frontend/public directory
|
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
public_dir = os.path.join(project_root, "frontend", "public")
|
|
|
|
if os.path.isdir(public_dir):
|
|
target_path = os.path.join(public_dir, "network.json")
|
|
try:
|
|
import json
|
|
with open(target_path, 'w') as f:
|
|
json.dump(network_json, f, indent=2)
|
|
logger.info(f"Generated {target_path} from network.yaml")
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate network.json: {e}")
|
|
else:
|
|
logger.warning(f"Frontend public directory not found at {public_dir}. Skipping network.json generation.")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="TFM aInventory Standalone Launcher")
|
|
parser.add_argument("action", nargs="?", choices=["start", "stop", "restart", "status", "run"], default="run",
|
|
help="Action to perform: run (foreground), start (background), stop, restart, status")
|
|
parser.add_argument("--no-proxy", action="store_true", help="Disable SSL proxy even if enabled in config")
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--backend-only", action="store_true", help="Operation on backend only")
|
|
group.add_argument("--frontend-only", action="store_true", help="Operation on frontend only")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Load all configs to find ports and settings
|
|
backend_cfg = load_yaml("config/backend.yaml")
|
|
frontend_cfg = load_yaml("config/frontend.yaml")
|
|
network_cfg = load_yaml("config/network.yaml")
|
|
|
|
ports_cfg = network_cfg.get("ports", {})
|
|
backend_port = ports_cfg.get("backend_port", 8916)
|
|
frontend_port = ports_cfg.get("frontend_port", 8917)
|
|
|
|
if args.action == "status":
|
|
action_status(backend_port, frontend_port, network_cfg)
|
|
return
|
|
|
|
if args.action == "stop":
|
|
action_stop(backend_port, frontend_port, network_cfg)
|
|
return
|
|
|
|
if args.action == "restart":
|
|
action_stop(backend_port, frontend_port, network_cfg)
|
|
# Fall through to start/run logic
|
|
args.action = "start" if args.action == "restart" else args.action
|
|
|
|
if args.action in ["start", "run"]:
|
|
background = (args.action == "start")
|
|
|
|
# Generate network.json for frontend discovery before starting anything
|
|
generate_network_json(network_cfg)
|
|
|
|
if not args.frontend_only:
|
|
if is_port_in_use(backend_port):
|
|
logger.error(f"Port {backend_port} is already in use. Backend cannot start.")
|
|
if not args.backend_only and args.action == "run":
|
|
sys.exit(1)
|
|
else:
|
|
start_backend(backend_port, backend_cfg, background=background)
|
|
|
|
if not args.backend_only:
|
|
if is_port_in_use(frontend_port):
|
|
logger.error(f"Port {frontend_port} is already in use. Frontend cannot start.")
|
|
if not args.frontend_only and args.action == "run":
|
|
sys.exit(1)
|
|
else:
|
|
start_frontend(frontend_port, frontend_cfg, network_cfg, background=background)
|
|
|
|
# Start SSL Proxy if enabled
|
|
if not args.no_proxy and not args.backend_only and not args.frontend_only:
|
|
start_proxy(network_cfg, background=background)
|
|
|
|
if not processes:
|
|
logger.error("No processes started. Check configuration and port availability.")
|
|
sys.exit(1)
|
|
|
|
# Save PIDs
|
|
save_pids([p.pid for p in processes])
|
|
|
|
if background:
|
|
logger.info(f"All services started in BACKGROUND. PIDs: {', '.join(str(p.pid) for p in processes)}")
|
|
logger.info(f"Use 'python3 {sys.argv[0]} status' to check state.")
|
|
return
|
|
|
|
logger.info("All services started in FOREGROUND. Press Ctrl+C to stop.")
|
|
|
|
# Monitor processes
|
|
try:
|
|
while not shutdown_event.is_set():
|
|
time.sleep(1)
|
|
for p in processes:
|
|
if p.poll() is not None:
|
|
logger.error(f"Process {p.pid} exited with code {p.returncode}")
|
|
shutdown_event.set()
|
|
break
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
signal_handler(None, None)
|
|
clear_pids()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|