refactor(api): move files/workloads endpoints into app_files router

This commit is contained in:
kodi
2026-02-27 13:55:09 +01:00
parent 65395cf7e8
commit 3d516c96e4
2 changed files with 170 additions and 166 deletions
+2 -166
View File
@@ -2,6 +2,7 @@ import os
import sys import sys
import subprocess import subprocess
from app_images import init_images_router from app_images import init_images_router
from app_files import init_files_router
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
import requests_unixsocket import requests_unixsocket
@@ -99,6 +100,7 @@ async def _startup_stats_poller():
# --- ROUTERS --- # --- ROUTERS ---
# Images API lives in a dedicated module to keep this file from growing further. # 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_images_router(SESSION, PODMAN_API_BASE))
app.include_router(init_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
# --- ADAPTERS (contract-neutral helpers) --- # --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation. # Centralize Podman socket and systemctl invocation.
@@ -162,172 +164,6 @@ def health():
return {"ok": ok, "podman": {"ok": podman_ok}, "systemd_user": {"reachable": systemd_reachable}} 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 --- # --- PODS / CONTAINERS ---
@app.get("/pods") @app.get("/pods")
def list_pods(): def list_pods():
+168
View File
@@ -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