diff --git a/backend/src/api/packet_scripting_api.py b/backend/src/api/packet_scripting_api.py index 4769fbd..0620182 100644 --- a/backend/src/api/packet_scripting_api.py +++ b/backend/src/api/packet_scripting_api.py @@ -1,40 +1,56 @@ -# script_router_stateless.py +# script_router_with_venv.py """ -Stateless APIRouter that manages uploaded scripts via systemd only. -- Upload/list/download scripts (stored in SCRIPT_DIR) -- Enable script on qnum => write systemd unit fw-script--q.service and start it -- Disable script on qnum => stop service and remove unit file -- Status endpoints discover active services by scanning /etc/systemd/system for fw-script-*.service -No DB or run-dir used — systemd is the source of truth. +APIRouter for uploading scripts (with optional requirements.txt), +creating per-script venvs (when requirements provided), and managing +systemd services that run scripts with a queue number argument. + +Usage: + from fastapi import FastAPI + from script_router_with_venv import router, register_lifecycle + app = FastAPI() + app.include_router(router) + register_lifecycle(app) # optional: stops/removes manager-created units on shutdown + +Notes: + - The router does NOT interact with nft. You should create/delete nft queue rules with your separate API. + - Script files are stored under SCRIPT_DIR. + - Virtualenvs (if created) are stored under VENV_BASE/. + - Systemd units are created under /etc/systemd/system with names: fw-script--q.service + - This code must run with permissions to create venvs, write unit files and call systemctl (typically root). """ import os +import sys import uuid +import shutil import subprocess import time import re import logging -from typing import List, Dict, Optional +from typing import Optional, List, Dict from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel -# ---------- config ---------- +# ---------- Config ---------- SCRIPT_DIR = "/srv/fw-scripts" +VENV_BASE = "/srv/fw-scripts/venvs" UNIT_DIR = "/etc/systemd/system" -UNIT_PREFIX = "fw-script" # unit names like fw-script--q.service +UNIT_PREFIX = "fw-script" +# ensure dirs exist os.makedirs(SCRIPT_DIR, exist_ok=True) +os.makedirs(VENV_BASE, exist_ok=True) -# ---------- logging ---------- +# ---------- Logging ---------- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") -logger = logging.getLogger("script-router-stateless") +logger = logging.getLogger("script-router-venv") -# ---------- router ---------- +# ---------- Router ---------- router = APIRouter(prefix="/scripts", tags=["scripts"]) -# ---------- models ---------- +# ---------- Models ---------- class ScriptInfo(BaseModel): id: str name: str @@ -44,17 +60,81 @@ class EnableRequest(BaseModel): qnum: int service_name: Optional[str] = None extra_args: Optional[str] = None + enable_at_boot: Optional[bool] = False -# ---------- helpers ---------- +# ---------- Utilities: service names / unit paths ---------- def make_service_name(sid: str, qnum: int) -> str: - # keep name safe for systemd return f"{UNIT_PREFIX}-{sid}-q{qnum}" def unit_path_for(service_name: str) -> str: return os.path.join(UNIT_DIR, service_name + ".service") -def write_unit(service_name: str, exec_start: str, description: str = "") -> str: - """Write unit file and daemon-reload systemd.""" +# ---------- Venv helpers ---------- +def venv_path_for(sid: str) -> str: + return os.path.join(VENV_BASE, sid) + +def venv_python_for(sid: str) -> str: + vpy = os.path.join(venv_path_for(sid), "bin", "python") + if os.path.exists(vpy): + return vpy + # fallback to system python + return "/usr/bin/python3" + +def create_venv(sid: str, timeout: int = 60): + venv_dir = venv_path_for(sid) + if os.path.exists(venv_dir): + logger.debug("Venv already exists for sid=%s: %s", sid, venv_dir) + return venv_dir + os.makedirs(os.path.dirname(venv_dir), exist_ok=True) + logger.info("Creating venv for sid=%s at %s", sid, venv_dir) + try: + subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True, timeout=timeout) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"venv creation failed: {e}") + except subprocess.TimeoutExpired: + raise RuntimeError("venv creation timed out") + return venv_dir + +def pip_install_requirements(sid: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]: + """ + Install requirements into the venv for sid from requirements_path. + Returns dict: { "stdout": "...", "stderr": "..." } + Raises on failure with details in exception message. + """ + venv_dir = create_venv(sid) + pip_path = os.path.join(venv_dir, "bin", "pip") + # ensure pip exists and upgrade + try: + subprocess.run([pip_path, "install", "--upgrade", "pip"], check=True, capture_output=True, text=True, timeout=300) + except subprocess.CalledProcessError as e: + # continue but warn + logger.warning("pip upgrade failed for sid=%s: %s", sid, e.stderr if hasattr(e, "stderr") else str(e)) + # install requirements + try: + p = subprocess.run([pip_path, "install", "-r", requirements_path, "--no-cache-dir"], + check=True, capture_output=True, text=True, timeout=timeout) + logger.info("pip install success for sid=%s", sid) + return {"stdout": p.stdout, "stderr": p.stderr} + except subprocess.CalledProcessError as e: + logger.error("pip install failed for sid=%s: %s", sid, e.stderr if hasattr(e, "stderr") else str(e)) + # return output for debugging + out = {"stdout": getattr(e, "stdout", "") or "", "stderr": getattr(e, "stderr", "") or str(e)} + raise RuntimeError(jsonify_cmd_output(out)) + except subprocess.TimeoutExpired: + logger.error("pip install timeout for sid=%s", sid) + raise RuntimeError("pip install timed out") + +def jsonify_cmd_output(out: Dict[str, str]) -> str: + # helper to pack stdout/stderr into a single string message + s = "" + if out.get("stdout"): + s += "STDOUT:\n" + out["stdout"] + "\n" + if out.get("stderr"): + s += "STDERR:\n" + out["stderr"] + "\n" + return s.strip() + +# ---------- systemd helpers ---------- +def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str: unit_path = unit_path_for(service_name) unit_text = f"""[Unit] Description={description} @@ -73,19 +153,28 @@ WantedBy=multi-user.target """ with open(unit_path, "w") as fh: fh.write(unit_text) - # reload systemd to pick up new unit + # reload systemd subprocess.run(["systemctl", "daemon-reload"], check=True) logger.info("Wrote unit %s", unit_path) + if enable_at_boot: + try: + subprocess.run(["systemctl", "enable", service_name], check=True) + logger.info("Enabled %s at boot", service_name) + except subprocess.CalledProcessError: + logger.warning("Failed to enable %s at boot", service_name) return unit_path def remove_unit(service_name: str): - """Stop and remove a unit file, then daemon-reload.""" + unit_path = unit_path_for(service_name) try: subprocess.run(["systemctl", "stop", service_name], check=False) except Exception: - logger.exception("Failed to stop %s", service_name) - unit_path = unit_path_for(service_name) + logger.exception("systemctl stop failed for %s", service_name) if os.path.exists(unit_path): + try: + subprocess.run(["systemctl", "disable", service_name], check=False) + except Exception: + pass os.remove(unit_path) subprocess.run(["systemctl", "daemon-reload"], check=True) logger.info("Removed unit %s", unit_path) @@ -94,6 +183,10 @@ def start_unit(service_name: str): subprocess.run(["systemctl", "start", service_name], check=True) logger.info("Started service %s", service_name) +def stop_unit(service_name: str): + subprocess.run(["systemctl", "stop", service_name], check=True) + logger.info("Stopped service %s", service_name) + def is_unit_active(service_name: str) -> bool: p = subprocess.run(["systemctl", "is-active", "--quiet", service_name]) return p.returncode == 0 @@ -101,60 +194,86 @@ def is_unit_active(service_name: str) -> bool: def list_fw_units() -> List[str]: """Return list of fw unit names without .service suffix.""" units = [] - # scan UNIT_DIR for files matching UNIT_PREFIX-*.service try: for fn in os.listdir(UNIT_DIR): if fn.startswith(UNIT_PREFIX + "-") and fn.endswith(".service"): - units.append(fn[:-8]) # strip .service + units.append(fn[:-8]) # remove .service except FileNotFoundError: logger.warning("Unit dir %s not found", UNIT_DIR) return units -# ExecStart parser: we expect ExecStart like "/usr/bin/python3 /srv/fw-scripts/.py [extra]" +# ExecStart parse pattern: python path + script path + qnum + optional extra _RE_EXECSTART = re.compile(r'(?P/\S*python\S*)\s+(?P