diff --git a/scripts/install_service.py b/scripts/install_service.py new file mode 100755 index 00000000..86d2139a --- /dev/null +++ b/scripts/install_service.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +TFM aInventory - Systemd Service Installer (v1.12.0) +Converted from install_service.sh to Python per Decision D-05. +""" + +import os +import sys +import yaml +import argparse +import subprocess +import logging +import pwd +import grp +from typing import Dict, Any + +# Color codes +class Colors: + RED = '\033[0;31m' + GREEN = '\033[0;32m' + YELLOW = '\033[1;33m' + BLUE = '\033[0;34m' + NC = '\033[0m' + +# Setup logging +logging.basicConfig(level=logging.INFO, format=f"{Colors.BLUE}[INFO]{Colors.NC} %(message)s") +logger = logging.getLogger("install_service") + +SERVICE_NAME = "ainventory.service" +SERVICE_PATH = f"/etc/systemd/system/{SERVICE_NAME}" + +# Inline template if file missing +DEFAULT_TEMPLATE = """[Unit] +Description=TFM aInventory Service +After=network.target + +[Service] +Type=simple +User={SERVICE_USER} +Group={SERVICE_USER} +WorkingDirectory={PROJECT_DIR} +Environment=PATH={PROJECT_DIR}/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin +ExecStart={PYTHON_PATH} {PROJECT_DIR}/scripts/run_standalone.py --backend-only +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +""" + +def load_yaml(file_path: str) -> Dict[str, Any]: + if not os.path.exists(file_path): + 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 {} + +def check_user_exists(username: str) -> bool: + try: + pwd.getpwnam(username) + return True + except KeyError: + return False + +def main(): + if os.getuid() != 0: + logger.error("This script must be run as root (use sudo)") + sys.exit(1) + + parser = argparse.ArgumentParser(description="TFM aInventory Systemd Service Installer") + parser.add_argument("--user", default="www-data", help="Service user (default: www-data)") + parser.add_argument("--force", action="store_true", help="Overwrite existing service file") + + args = parser.parse_args() + + project_dir = os.getcwd() + python_path = sys.executable + + # Load configs + backend_cfg = load_yaml("config/backend.yaml") + network_cfg = load_yaml("config/network.yaml") + + app_cfg = backend_cfg.get("application", {}) + ports_cfg = network_cfg.get("ports", {}) + + backend_port = ports_cfg.get("backend_port", 8916) + data_dir = os.path.abspath(app_cfg.get("data_dir", "./data")) + logs_dir = os.path.abspath(app_cfg.get("logs_dir", "./logs")) + + logger.info(f"Installing {SERVICE_NAME}...") + + if not check_user_exists(args.user): + logger.warning(f"User '{args.user}' does not exist. Suggestion: sudo useradd -r -s /bin/false {args.user}") + # We'll continue but warn, as user might want to create it later (though service won't start) + + if os.path.exists(SERVICE_PATH) and not args.force: + logger.error(f"Service file {SERVICE_PATH} already exists. Use --force to overwrite.") + sys.exit(1) + + # Read template if exists + template_content = DEFAULT_TEMPLATE + if os.path.exists("inventory.service.template"): + try: + with open("inventory.service.template", "r") as f: + content = f.read() + if content.strip(): + template_content = content + # If using old template with __WORKING_DIR__, replace it + template_content = template_content.replace("__WORKING_DIR__", "{PROJECT_DIR}") + except Exception as e: + logger.warning(f"Failed to read inventory.service.template: {e}. Using default.") + + # Format template + # Note: We need to handle keys that might not be in the template but are in our variables + # The plan mentions {PROJECT_DIR}, {SERVICE_USER}, {BACKEND_PORT}, {DATA_DIR}, {LOGS_DIR} + try: + # We'll use a safer way to replace placeholders to avoid KeyError if template doesn't have all of them + service_content = template_content.format( + PROJECT_DIR=project_dir, + SERVICE_USER=args.user, + BACKEND_PORT=backend_port, + DATA_DIR=data_dir, + LOGS_DIR=logs_dir, + PYTHON_PATH=python_path + ) + except KeyError as e: + # Fallback if template has unknown placeholders + logger.warning(f"Template contains unknown placeholder: {e}. Attempting simple replacement.") + service_content = template_content.replace("{PROJECT_DIR}", project_dir)\ + .replace("{SERVICE_USER}", args.user)\ + .replace("{BACKEND_PORT}", str(backend_port))\ + .replace("{DATA_DIR}", data_dir)\ + .replace("{LOGS_DIR}", logs_dir)\ + .replace("{PYTHON_PATH}", python_path) + + # Write service file + try: + with open(SERVICE_PATH, "w") as f: + f.write(service_content) + os.chmod(SERVICE_PATH, 0o644) + logger.info(f"✓ Service file created at {SERVICE_PATH}") + except Exception as e: + logger.error(f"Failed to write service file: {e}") + sys.exit(1) + + # Ensure data and logs directories exist and are owned by the service user + for d in [data_dir, logs_dir]: + os.makedirs(d, exist_ok=True) + try: + uid = pwd.getpwnam(args.user).pw_uid + gid = grp.getgrnam(args.user).gr_gid + os.chown(d, uid, gid) + # Also chown contents if any + for root, dirs, files in os.walk(d): + for momo in dirs: os.chown(os.path.join(root, momo), uid, gid) + for momo in files: os.chown(os.path.join(root, momo), uid, gid) + except Exception as e: + logger.warning(f"Could not set permissions for {d}: {e}") + + # Reload systemd + try: + subprocess.run(["systemctl", "daemon-reload"], check=True) + subprocess.run(["systemctl", "enable", SERVICE_NAME], check=True) + logger.info(f"✓ {SERVICE_NAME} enabled") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to enable service: {e}") + sys.exit(1) + + print(f"\n{Colors.GREEN}🚀 TFM aInventory service installed successfully!{Colors.NC}") + print(f"Service user: {args.user}") + print(f"Project dir: {project_dir}") + print("\nNext steps:") + print(f" 👉 sudo systemctl start {SERVICE_NAME}") + print(f" 👉 sudo systemctl status {SERVICE_NAME}") + print(f" 👉 journalctl -u {SERVICE_NAME} -f") + +if __name__ == "__main__": + main()