add delete script
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user