add delete script
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2026-01-28 19:10:00 +01:00
parent eded798271
commit 1b4688eacc

View File

@@ -1,36 +1,31 @@
# script_router_named.py
# script_router_named_with_delete.py
"""
APIRouter: upload scripts with a supplied name, optional requirements -> create per-script venv.
If venv install fails, response includes pip output and the router deletes the uploaded files and venv.
Added: DELETE /scripts/{name} to disable any enabled services for that script and remove files/venv.
Endpoints:
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
- GET /scripts -> list scripts
- GET /scripts/{name} -> download script
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
- POST /scripts/{name}/disable -> disable service for script on qnum
- DELETE /scripts/{name} -> disable all or a specific qnum service(s) and delete script + venv + requirements
- GET /scripts/status -> status of all fw-script units
- GET /scripts/{name}/status -> status of units for that script
Notes:
- This relies on systemd and writes units to /etc/systemd/system
- Script files are stored at SCRIPT_DIR/<name>.py
- Venv stored at VENV_BASE/<name> (if requirements provided)
- 'name' must match regex [A-Za-z0-9_.-]+ (no path separators)
"""
import os
import sys
import re
import uuid
import json
import shutil
import subprocess
import time
import logging
from typing import Optional, List, Dict
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -46,13 +41,12 @@ os.makedirs(VENV_BASE, exist_ok=True)
# ---------- Logging ----------
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("script-router-named")
logger = logging.getLogger("script-router-named-delete")
# ---------- Router ----------
router = APIRouter(prefix="/scripts", tags=["scripts"])
# ---------- Name validation ----------
# Accept only safe file-name characters to avoid path traversal: letters, digits, dot, underscore, hyphen
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
def validate_name(name: str) -> None:
@@ -60,7 +54,6 @@ def validate_name(name: str) -> None:
raise ValueError("name must be provided")
if not _NAME_RE.match(name):
raise ValueError("invalid name; allowed characters: letters, digits, dot, underscore, hyphen")
# prevent reserved names or dots-only
if name in (".", ".."):
raise ValueError("invalid name")
@@ -105,18 +98,12 @@ def create_venv(name: str, timeout: int = 60) -> str:
return venv_dir
def pip_install_requirements(name: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
"""
Install requirements into the venv for name from requirements_path.
Returns dict with stdout/stderr. Raises RuntimeError on failure including outputs.
"""
venv_dir = create_venv(name)
pip_path = os.path.join(venv_dir, "bin", "pip")
# ensure pip exists and attempt to upgrade
try:
subprocess.run([pip_path, "install", "--upgrade", "pip"], check=True, capture_output=True, text=True, timeout=300)
except subprocess.CalledProcessError as e:
logger.warning("pip upgrade warning for %s: %s", name, getattr(e, "stderr", str(e)))
# run install
try:
p = subprocess.run([pip_path, "install", "-r", requirements_path, "--no-cache-dir"],
check=True, capture_output=True, text=True, timeout=timeout)
@@ -168,7 +155,7 @@ WantedBy=multi-user.target
logger.warning("Failed to enable %s at boot", service_name)
return unit_path
def remove_unit(service_name: str):
def remove_unit(service_name: str) -> None:
unit_path = unit_path_for(service_name)
try:
subprocess.run(["systemctl", "stop", service_name], check=False)
@@ -183,11 +170,11 @@ def remove_unit(service_name: str):
subprocess.run(["systemctl", "daemon-reload"], check=True)
logger.info("Removed unit %s", unit_path)
def start_unit(service_name: str):
def start_unit(service_name: str) -> None:
subprocess.run(["systemctl", "start", service_name], check=True)
logger.info("Started service %s", service_name)
def stop_unit(service_name: str):
def stop_unit(service_name: str) -> None:
subprocess.run(["systemctl", "stop", service_name], check=True)
logger.info("Stopped service %s", service_name)
@@ -196,7 +183,6 @@ def is_unit_active(service_name: str) -> bool:
return p.returncode == 0
def list_fw_units() -> List[str]:
"""List our fw-script units (without .service suffix)."""
units = []
try:
for fn in os.listdir(UNIT_DIR):
@@ -206,7 +192,6 @@ def list_fw_units() -> List[str]:
logger.warning("Unit dir %s not found", UNIT_DIR)
return units
# ExecStart parse pattern: python + /srv/fw-scripts/<name>.py + qnum + optional extra
_RE_EXECSTART = re.compile(r'(?P<py>/\S*python\S*)\s+(?P<script>/\S*?/srv/fw-scripts/(?P<name>[A-Za-z0-9_.-]+)\.py)\s+(?P<qnum>\d+)(?:\s+(?P<extra>.*))?')
def parse_unit_execstart(service_name: str) -> Optional[Dict]:
@@ -244,29 +229,21 @@ async def upload_script(
name: str = Form(...),
requirements: Optional[UploadFile] = File(None),
):
"""
Upload a script with a supplied name (Form field 'name'), optional requirements file.
If requirements provided, create venv and install; on failure delete files and venv and return error details.
"""
# validate name
try:
validate_name(name)
except ValueError as e:
logger.warning("Invalid name provided: %s", name)
raise HTTPException(status_code=400, detail=str(e))
# ensure script file extension is .py
if not script.filename.endswith(".py"):
logger.warning("Upload rejected: script not .py (name=%s original=%s)", name, script.filename)
raise HTTPException(status_code=400, detail="only .py scripts allowed")
# ensure uniqueness
spath = script_path_for(name)
if os.path.exists(spath):
logger.warning("Upload rejected: script with name already exists: %s", name)
raise HTTPException(status_code=409, detail="script with that name already exists")
# write script
data = await script.read()
try:
with open(spath, "wb") as fh:
@@ -283,25 +260,21 @@ async def upload_script(
try:
if requirements is not None:
# save requirements file
req_data = await requirements.read()
req_path = requirements_path_for(name)
with open(req_path, "wb") as fh:
fh.write(req_data)
logger.info("Saved requirements for %s at %s", name, req_path)
# create venv and install
try:
# create venv and pip install
create_venv(name)
venv_created = True
res = pip_install_requirements(name, req_path)
pip_output = {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}
logger.info("pip install completed for %s", name)
except Exception as pip_exc:
# pip install failed: cleanup and report
err_msg = str(pip_exc)
logger.error("pip install error for %s: %s", name, err_msg[:2000])
# cleanup: remove script file, req file, venv dir if created
# cleanup
try:
if os.path.exists(spath):
os.remove(spath)
@@ -311,13 +284,10 @@ async def upload_script(
shutil.rmtree(venv_path_for(name), ignore_errors=True)
except Exception:
logger.exception("Cleanup after pip failure partially failed for %s", name)
# respond with failure detail (include pip output if available)
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
except HTTPException:
# pass-through to ensure upstream returns after cleanup
raise
except Exception as e:
# unexpected failure: attempt cleanup of script + req + venv
logger.exception("Unexpected error during upload for %s: %s", name, e)
try:
if os.path.exists(spath):
@@ -336,7 +306,6 @@ async def upload_script(
pass
raise HTTPException(status_code=500, detail="internal error during upload")
# success
resp = {"name": name, "path": spath}
if pip_output is not None:
resp["pip"] = pip_output
@@ -415,7 +384,6 @@ def disable_script(name: str, qnum: int):
service_name = make_service_name(name, qnum)
unit_p = unit_path_for(service_name)
if not os.path.exists(unit_p):
# attempt to stop anyway
try:
subprocess.run(["systemctl", "stop", service_name], check=False)
except Exception:
@@ -432,6 +400,117 @@ def disable_script(name: str, qnum: int):
logger.info("Disabled script %s on qnum=%s (removed service %s)", name, qnum, service_name)
return {"status": "ok", "name": name, "qnum": qnum}
@router.delete("/{name}")
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")):
"""
Delete a script and its associated resources.
- If qnum is provided: stop/remove fw-script-<name>-q<qnum>.service (if present).
- If qnum is not provided: stop/remove all fw-script-<name>-q*.service units found.
- Remove script file, requirements file, and venv directory.
Returns JSON summarizing performed actions and any errors.
"""
try:
validate_name(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
removed_units: List[str] = []
failed_units: List[str] = []
errors: List[str] = []
# determine which units to remove
if qnum is not None:
svc = make_service_name(name, qnum)
units_to_handle = [svc]
else:
# scan unit directory for matching units
all_units = list_fw_units()
prefix = f"{UNIT_PREFIX}-{name}-q"
units_to_handle = [u for u in all_units if u.startswith(prefix)]
# stop and remove units
for svc in units_to_handle:
try:
# attempt to stop via systemctl even if unit file missing
try:
subprocess.run(["systemctl", "stop", svc], check=False)
except Exception:
pass
unit_file = unit_path_for(svc)
if os.path.exists(unit_file):
try:
remove_unit(svc)
removed_units.append(svc)
except Exception as e:
logger.exception("Failed to remove unit %s: %s", svc, e)
failed_units.append(svc)
errors.append(f"remove_unit {svc}: {e}")
else:
# unit file doesn't exist - treat as stopped/absent but attempt systemd stop above
removed_units.append(svc)
except Exception as e:
logger.exception("Error handling unit %s: %s", svc, e)
failed_units.append(svc)
errors.append(f"error handling {svc}: {e}")
# remove files: script, requirements, venv
spath = script_path_for(name)
rpath = requirements_path_for(name)
vpath = venv_path_for(name)
file_removed = []
file_failed = []
# remove script file
try:
if os.path.exists(spath):
os.remove(spath)
file_removed.append(spath)
logger.info("Removed script file %s", spath)
else:
logger.debug("Script file %s not present", spath)
except Exception as e:
logger.exception("Failed removing script file %s: %s", spath, e)
file_failed.append(spath)
errors.append(f"remove_script {spath}: {e}")
# remove requirements file
try:
if os.path.exists(rpath):
os.remove(rpath)
file_removed.append(rpath)
logger.info("Removed requirements file %s", rpath)
else:
logger.debug("Requirements file %s not present", rpath)
except Exception as e:
logger.exception("Failed removing requirements file %s: %s", rpath, e)
file_failed.append(rpath)
errors.append(f"remove_requirements {rpath}: {e}")
# remove venv dir
try:
if os.path.isdir(vpath):
shutil.rmtree(vpath, ignore_errors=False)
file_removed.append(vpath)
logger.info("Removed venv directory %s", vpath)
else:
logger.debug("Venv dir %s not present", vpath)
except Exception as e:
logger.exception("Failed removing venv %s: %s", vpath, e)
file_failed.append(vpath)
errors.append(f"remove_venv {vpath}: {e}")
result = {
"name": name,
"units_removed": removed_units,
"units_failed": failed_units,
"files_removed": file_removed,
"files_failed": file_failed,
"errors": errors,
}
logger.info("Delete script %s completed: removed_units=%d files_removed=%d errors=%d", name, len(removed_units), len(file_removed), len(errors))
return result
@router.get("/status")
def status_all():
units = list_fw_units()