diff --git a/control/app.py b/control/app.py index 0fa77c5..298e689 100644 --- a/control/app.py +++ b/control/app.py @@ -2,6 +2,7 @@ import os import sys import subprocess from app_images import init_images_router +from app_files import init_files_router from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel import requests_unixsocket @@ -99,6 +100,7 @@ async def _startup_stats_poller(): # --- ROUTERS --- # Images API lives in a dedicated module to keep this file from growing further. app.include_router(init_images_router(SESSION, PODMAN_API_BASE)) +app.include_router(init_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR)) # --- ADAPTERS (contract-neutral helpers) --- # Centralize Podman socket and systemctl invocation. @@ -162,172 +164,6 @@ def health(): return {"ok": ok, "podman": {"ok": podman_ok}, "systemd_user": {"reachable": systemd_reachable}} -# --- MODELS --- -class FileContent(BaseModel): - content: str - - -# --- WORKLOADS --- -@app.get("/workloads") -def list_workloads(): - workloads = [] - for root, _, files in os.walk(WORKLOADS_DIR): - for f in files: - if f.endswith((".yaml", ".yml", ".json")): - full = os.path.join(root, f) - rel = os.path.relpath(full, WORKLOADS_DIR) - workloads.append(rel) - return {"workloads": workloads} - - -@app.get("/workloads/read/{filename:path}") -def read_workload(filename: str): - path = os.path.join(WORKLOADS_DIR, filename) - if not os.path.exists(path): - raise HTTPException(404) - with open(path, 'r') as f: - content = f.read() - return {"filename": filename, "content": content} - - -@app.post("/workloads/save-file") -def save_workload_file(data: dict): - path = data.get("path") - content = data.get("content") - full_path = os.path.join(WORKLOADS_DIR, path) - os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open(full_path, "w") as f: - f.write(content) - return {"status": "success"} - - -@app.post("/workloads/deploy/{filename:path}") -def deploy_workload(filename: str): - path = os.path.join(WORKLOADS_DIR, filename) - with open(path, 'r') as f: - yaml_content = f.read() - url = f"{PODMAN_API_BASE}/libpod/kube/play" - return _podman_post(url, data=yaml_content).json() - - -# --- FILE RESTRICTIONS --- -def safe_join(base, path): - # prevent traversal - base = os.path.abspath(base) - final = os.path.abspath(os.path.join(base, path)) - if not final.startswith(base): - raise HTTPException(status_code=403, detail="Forbidden path") - return final - - -# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper. -# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls. -def _files_safe_join(path: str) -> str: - return safe_join(WORKLOADS_DIR, path) - - -# --- FILES API --- -@app.get("/files/tree") -def file_tree(): - root = WORKLOADS_DIR - result = [] - for dirpath, dirnames, filenames in os.walk(root): - rel = os.path.relpath(dirpath, root) - if rel == ".": - rel = "" - result.append({ - "path": rel, - "dirs": sorted(dirnames), - "files": sorted(filenames), - }) - return result - - -@app.get("/files/read") -def file_read(path: str = Query(...)): - full = _files_safe_join(path) - if not os.path.exists(full): - raise HTTPException(status_code=404, detail="Not found") - if os.path.isdir(full): - raise HTTPException(status_code=403, detail="Is a directory") - with open(full, "r") as f: - content = f.read() - return {"content": content} - - -@app.post("/files/save") -def file_save(path: str = Query(...), data: FileContent = None): - full = _files_safe_join(path) - os.makedirs(os.path.dirname(full), exist_ok=True) - with open(full, "w") as f: - f.write(data.content) - return {"status": "success", "path": path} - - -@app.delete("/files/delete") -def file_delete(path: str = Query(...)): - full = _files_safe_join(path) - if not os.path.exists(full): - raise HTTPException(status_code=404, detail="Not found") - if os.path.isdir(full): - raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory") - try: - os.remove(full) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}") - return {"status": "deleted", "type": "file"} - - -@app.post("/files/mkdir") -def file_mkdir(path: str = Query(...)): - # UI expects operations under systemd/; enforce prefix if absent. - if not path.startswith("systemd"): - path = os.path.join("systemd", path) - full = _files_safe_join(path) - os.makedirs(full, exist_ok=True) - return {"status": "directory created", "path": path} - - -@app.delete("/files/rmdir") -def file_rmdir(path: str = Query(..., description="Directory path under systemd/")): - # Only allow deletion under systemd subtree - if not path or path == "systemd" or path == "systemd/": - raise HTTPException(status_code=400, detail="Refusing to delete systemd root") - if not path.startswith("systemd/") and path != "systemd": - raise HTTPException(status_code=400, detail="Only systemd subtree is allowed") - - full = _files_safe_join(path) - if not os.path.exists(full): - raise HTTPException(status_code=404, detail="Directory not found") - if not os.path.isdir(full): - raise HTTPException(status_code=400, detail="Path is not a directory") - - # directory must be empty - try: - Path(full).rmdir() - except OSError: - # not empty - # build a stable detail payload - try: - dirs = [] - files = [] - for entry in os.listdir(full): - p = os.path.join(full, entry) - if os.path.isdir(p): - dirs.append(entry) - else: - files.append(entry) - except Exception: - dirs, files = [], [] - raise HTTPException(status_code=409, detail={ - "error": "directory not empty", - "dirs": sorted(dirs), - "files": sorted(files), - }) - - return {"deleted": True, "path": path} - - # --- PODS / CONTAINERS --- @app.get("/pods") def list_pods(): diff --git a/control/app_files.py b/control/app_files.py new file mode 100644 index 0000000..a3ff889 --- /dev/null +++ b/control/app_files.py @@ -0,0 +1,168 @@ +import os +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + + +class FileContent(BaseModel): + content: str + + +def safe_join(base, path): + # prevent traversal + base = os.path.abspath(base) + final = os.path.abspath(os.path.join(base, path)) + if not final.startswith(base): + raise HTTPException(status_code=403, detail="Forbidden path") + return final + + +def init_files_router(session, podman_api_base: str, workloads_dir: str) -> APIRouter: + router = APIRouter(tags=["files"]) + + def _podman_post(url: str, **kwargs): + # Keep behavior identical to app.py wrapper used by old /workloads/deploy. + return session.post(url, **kwargs) + + # STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper. + # MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls. + def _files_safe_join(path: str) -> str: + return safe_join(workloads_dir, path) + + # --- WORKLOADS --- + @router.get("/workloads") + def list_workloads(): + workloads = [] + for root, _, files in os.walk(workloads_dir): + for f in files: + if f.endswith((".yaml", ".yml", ".json")): + full = os.path.join(root, f) + rel = os.path.relpath(full, workloads_dir) + workloads.append(rel) + return {"workloads": workloads} + + @router.get("/workloads/read/{filename:path}") + def read_workload(filename: str): + path = os.path.join(workloads_dir, filename) + if not os.path.exists(path): + raise HTTPException(404) + with open(path, 'r') as f: + content = f.read() + return {"filename": filename, "content": content} + + @router.post("/workloads/save-file") + def save_workload_file(data: dict): + path = data.get("path") + content = data.get("content") + full_path = os.path.join(workloads_dir, path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return {"status": "success"} + + @router.post("/workloads/deploy/{filename:path}") + def deploy_workload(filename: str): + path = os.path.join(workloads_dir, filename) + with open(path, 'r') as f: + yaml_content = f.read() + url = f"{podman_api_base}/libpod/kube/play" + return _podman_post(url, data=yaml_content).json() + + # --- FILES API --- + @router.get("/files/tree") + def file_tree(): + root = workloads_dir + result = [] + for dirpath, dirnames, filenames in os.walk(root): + rel = os.path.relpath(dirpath, root) + if rel == ".": + rel = "" + result.append({ + "path": rel, + "dirs": sorted(dirnames), + "files": sorted(filenames), + }) + return result + + @router.get("/files/read") + def file_read(path: str = Query(...)): + full = _files_safe_join(path) + if not os.path.exists(full): + raise HTTPException(status_code=404, detail="Not found") + if os.path.isdir(full): + raise HTTPException(status_code=403, detail="Is a directory") + with open(full, "r") as f: + content = f.read() + return {"content": content} + + @router.post("/files/save") + def file_save(path: str = Query(...), data: FileContent = None): + full = _files_safe_join(path) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w") as f: + f.write(data.content) + return {"status": "success", "path": path} + + @router.delete("/files/delete") + def file_delete(path: str = Query(...)): + full = _files_safe_join(path) + if not os.path.exists(full): + raise HTTPException(status_code=404, detail="Not found") + if os.path.isdir(full): + raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory") + try: + os.remove(full) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}") + return {"status": "deleted", "type": "file"} + + @router.post("/files/mkdir") + def file_mkdir(path: str = Query(...)): + # UI expects operations under systemd/; enforce prefix if absent. + if not path.startswith("systemd"): + path = os.path.join("systemd", path) + full = _files_safe_join(path) + os.makedirs(full, exist_ok=True) + return {"status": "directory created", "path": path} + + @router.delete("/files/rmdir") + def file_rmdir(path: str = Query(..., description="Directory path under systemd/")): + # Only allow deletion under systemd subtree + if not path or path == "systemd" or path == "systemd/": + raise HTTPException(status_code=400, detail="Refusing to delete systemd root") + if not path.startswith("systemd/") and path != "systemd": + raise HTTPException(status_code=400, detail="Only systemd subtree is allowed") + + full = _files_safe_join(path) + if not os.path.exists(full): + raise HTTPException(status_code=404, detail="Directory not found") + if not os.path.isdir(full): + raise HTTPException(status_code=400, detail="Path is not a directory") + + # directory must be empty + try: + Path(full).rmdir() + except OSError: + # not empty + # build a stable detail payload + try: + dirs = [] + files = [] + for entry in os.listdir(full): + p = os.path.join(full, entry) + if os.path.isdir(p): + dirs.append(entry) + else: + files.append(entry) + except Exception: + dirs, files = [], [] + raise HTTPException(status_code=409, detail={ + "error": "directory not empty", + "dirs": sorted(dirs), + "files": sorted(files), + }) + + return {"deleted": True, "path": path} + + return router