refactor(api): move files/workloads endpoints into app_files router
This commit is contained in:
+2
-166
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user