Opruimen na installatie Gitea
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import requests_unixsocket
|
||||
import os
|
||||
import yaml
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
# Podman API URL format voor unix sockets
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
files = [f for f in os.listdir(WORKLOADS_DIR) if f.endswith('.yaml')]
|
||||
return {"workloads": files}
|
||||
|
||||
@app.post("/workloads/deploy/{filename}")
|
||||
def deploy_workload(filename: str):
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="YAML niet gevonden")
|
||||
|
||||
with open(path, 'r') as f:
|
||||
# Podman Kube Play endpoint: /libpod/kube/play
|
||||
# We versturen de content direct als body
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
response = SESSION.post(url, data=f.read())
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=response.status_code, detail=response.json())
|
||||
|
||||
return response.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
@@ -1,68 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import requests_unixsocket
|
||||
import yaml # Voor eventuele toekomstige validatie
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane")
|
||||
|
||||
# Gebruik requests-unixsocket om met de Podman socket te praten
|
||||
SESSION = requests_unixsocket.Session()
|
||||
|
||||
# Podman API URL: %2F is de slash '/' voor de unix socket
|
||||
# We gebruiken v5.4.2 zoals gespecificeerd
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
"""Scant recursief naar .yaml bestanden in de gemounte map."""
|
||||
yaml_files = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith(".yaml"):
|
||||
# Maak een pad relatief aan de workloads map
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/workloads/deploy/{filename:path}")
|
||||
def deploy_workload(filename: str):
|
||||
"""Stuurt de inhoud van een YAML naar de Podman API (kube play)."""
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail=f"Bestand {filename} niet gevonden")
|
||||
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
|
||||
# Podman Kube Play endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
|
||||
# We versturen de platte tekst van het YAML bestand naar de API
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"status": "error",
|
||||
"podman_status_code": response.status_code,
|
||||
"details": response.json()
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Deployment van {filename} gestart",
|
||||
"podman_response": response.json()
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# Belangrijk: host 0.0.0.0 zodat hij bereikbaar is buiten de container
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,59 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane")
|
||||
|
||||
# Gebruik requests-unixsocket voor communicatie met de Podman socket
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/workloads/deploy/{filename:path}")
|
||||
def deploy_workload(filename: str):
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/workloads/create-folder/{foldername:path}")
|
||||
def create_folder(foldername: str):
|
||||
target_path = os.path.join(WORKLOADS_DIR, foldername)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
return {"status": "success", "message": f"Map {foldername} aangemaakt"}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,73 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane")
|
||||
|
||||
# Gebruik requests-unixsocket voor communicatie met de Podman socket
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/workloads/deploy/{filename:path}")
|
||||
def deploy_workload(filename: str):
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
yaml_content = f.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/workloads/create-folder/{foldername:path}")
|
||||
def create_folder(foldername: str):
|
||||
target_path = os.path.join(WORKLOADS_DIR, foldername)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
return {"status": "success", "message": f"Map {foldername} aangemaakt"}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
"""Haalt de lijst met pods en hun status op via de Podman API."""
|
||||
try:
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json"
|
||||
response = SESSION.get(url)
|
||||
if response.status_code != 200:
|
||||
return {"status": "error", "details": response.json()}
|
||||
|
||||
# We geven de ruwe lijst met pods terug
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,87 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane")
|
||||
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/workloads/read/{filename:path}")
|
||||
def read_workload(filename: str):
|
||||
"""Leest de inhoud van een specifiek YAML bestand."""
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
|
||||
with open(path, 'r') as f:
|
||||
return {"filename": filename, "content": f.read()}
|
||||
|
||||
@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"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
# --- NIEUWE MANAGEMENT ENDPOINTS ---
|
||||
|
||||
@app.post("/pods/stop/{name}")
|
||||
def stop_pod(name: str):
|
||||
"""Stopt alle containers in een pod."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code == 200 else "error", "code": response.status_code}
|
||||
|
||||
@app.post("/pods/start/{name}")
|
||||
def start_pod(name: str):
|
||||
"""Start alle containers in een pod."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code == 200 else "error", "code": response.status_code}
|
||||
|
||||
@app.post("/pods/rm/{name}")
|
||||
def remove_pod(name: str):
|
||||
"""Verwijdert een pod (geforceerd)."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
|
||||
response = SESSION.delete(url)
|
||||
return {"status": "success" if response.status_code == 201 or response.status_code == 200 else "error"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,90 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
# We voegen 'root_path' toe zodat de /docs pagina werkt achter Nginx
|
||||
app = FastAPI(
|
||||
title="Podman MVP Control Plane",
|
||||
root_path="/api"
|
||||
)
|
||||
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404, detail="Bestand niet gevonden")
|
||||
with open(path, 'r') as f:
|
||||
return {"filename": filename, "content": f.read()}
|
||||
|
||||
@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"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/pods/inspect/{name}")
|
||||
def inspect_pod(name: str):
|
||||
"""Geeft alle technische details van een pod (mounts, poorten, etc)."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
|
||||
response = SESSION.get(url)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=response.status_code, detail="Pod niet gevonden")
|
||||
return response.json()
|
||||
|
||||
@app.post("/pods/stop/{name}")
|
||||
def stop_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
|
||||
|
||||
@app.post("/pods/start/{name}")
|
||||
def start_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 304] else "error"}
|
||||
|
||||
@app.post("/pods/rm/{name}")
|
||||
def remove_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
|
||||
response = SESSION.delete(url)
|
||||
return {"status": "success" if response.status_code in [200, 201] else "error"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,121 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(
|
||||
title="Podman MVP Control Plane",
|
||||
root_path="/api"
|
||||
)
|
||||
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
# --- WORKLOAD BEHEER ---
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404, detail="Bestand niet gevonden")
|
||||
with open(path, 'r') as f:
|
||||
return {"filename": filename, "content": f.read()}
|
||||
|
||||
@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"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
@app.post("/workloads/delete/{filename:path}")
|
||||
def delete_workload(filename: str):
|
||||
"""Verwijdert een YAML bestand fysiek van de schijf."""
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return {"status": "success", "message": f"{filename} verwijderd"}
|
||||
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
|
||||
|
||||
# --- POD BEHEER ---
|
||||
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/pods/inspect/{name}")
|
||||
def inspect_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/pods/logs/{name}")
|
||||
def get_pod_logs(name: str):
|
||||
"""Haalt de logs op van de pod. Let op: dit kan veel tekst zijn."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/logs?stdout=true&stderr=true&tail=100"
|
||||
response = SESSION.get(url)
|
||||
return {"logs": response.text}
|
||||
|
||||
@app.post("/pods/stop/{name}")
|
||||
def stop_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
|
||||
|
||||
@app.post("/pods/start/{name}")
|
||||
def start_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 304] else "error"}
|
||||
|
||||
@app.post("/pods/restart/{name}")
|
||||
def restart_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/restart"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 204] else "error"}
|
||||
|
||||
@app.post("/pods/rm/{name}")
|
||||
def remove_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
|
||||
response = SESSION.delete(url)
|
||||
return {"status": "success" if response.status_code in [200, 201, 204] else "error"}
|
||||
|
||||
# --- SYSTEEM ---
|
||||
|
||||
@app.get("/system/info")
|
||||
def system_info():
|
||||
"""Geeft algemene info over de Podman host."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/info"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,131 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(
|
||||
title="Podman MVP Control Plane",
|
||||
root_path="/api"
|
||||
)
|
||||
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
# --- WORKLOAD BEHEER ---
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404, detail="Bestand niet gevonden")
|
||||
with open(path, 'r') as f:
|
||||
return {"filename": filename, "content": f.read()}
|
||||
|
||||
@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"
|
||||
response = SESSION.post(url, data=yaml_content)
|
||||
return {"status": "success", "podman_response": response.json()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f:
|
||||
f.write(file_data.content)
|
||||
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
|
||||
|
||||
@app.post("/workloads/delete/{filename:path}")
|
||||
def delete_workload(filename: str):
|
||||
"""Verwijdert een YAML bestand fysiek van de schijf."""
|
||||
path = os.path.join(WORKLOADS_DIR, filename)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return {"status": "success", "message": f"{filename} verwijderd"}
|
||||
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
|
||||
|
||||
# --- POD BEHEER ---
|
||||
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/pods/inspect/{name}")
|
||||
def inspect_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
"""Haalt de logs op van een specifieke container (ook als deze in een pod zit)."""
|
||||
# We voegen 'follow=false' toe om te voorkomen dat de API blijft hangen
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=50&follow=false"
|
||||
response = SESSION.get(url)
|
||||
if response.status_code != 200:
|
||||
return {"logs": f"Kon logs voor {name} niet vinden (Code {response.status_code})"}
|
||||
return {"logs": response.text}
|
||||
|
||||
@app.post("/pods/stop/{name}")
|
||||
def stop_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
|
||||
|
||||
@app.post("/pods/start/{name}")
|
||||
def start_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 304] else "error"}
|
||||
|
||||
@app.post("/pods/restart/{name}")
|
||||
def restart_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/restart"
|
||||
response = SESSION.post(url)
|
||||
return {"status": "success" if response.status_code in [200, 204] else "error"}
|
||||
|
||||
@app.post("/pods/rm/{name}")
|
||||
def remove_pod(name: str):
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
|
||||
response = SESSION.delete(url)
|
||||
return {"status": "success" if response.status_code in [200, 201, 204] else "error"}
|
||||
|
||||
# --- SYSTEEM ---
|
||||
|
||||
@app.get("/system/info")
|
||||
def system_info():
|
||||
"""Geeft algemene info over de Podman host."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/info"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
"""Haalt alle containers op met details zoals status, poorten en pods."""
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
response = SESSION.get(url)
|
||||
return response.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,75 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/pods/{action}/{name}")
|
||||
def pod_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/pods/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,178 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# 1. Haal alle containers op om te weten welke we moeten stoppen
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
matching = [c for c in api_containers if name in c['Names'][0]]
|
||||
|
||||
if action == "stop":
|
||||
for c in matching:
|
||||
# We gebruiken de ID om specifiek te zijn
|
||||
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
|
||||
return {"status": "stopped", "count": len(matching)}
|
||||
|
||||
elif action == "start":
|
||||
# We zoeken het pad van het bestand dat bij deze naam hoort
|
||||
# (Dit doen we door even snel de map te scannen)
|
||||
target_path = None
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith(('.yaml', '.kube')):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path and target_path.endswith('.yaml'):
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
# Start via de Kube Play API
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
res = SESSION.post(url, data=yaml_content)
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown action or file not found"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
"""Starten/Stoppen via de API."""
|
||||
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
|
||||
if action == "start":
|
||||
# Hier zouden we 'podman kube play' logica doen
|
||||
pass
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
|
||||
SESSION.post(url)
|
||||
return {"status": "request sent"}
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,191 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# 1. Haal alle containers op
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
matching = [c for c in api_containers if name in c['Names'][0]]
|
||||
|
||||
# 2. Zoek het bestand op schijf om te bepalen wat voor type het is
|
||||
target_path = None
|
||||
file_ext = ""
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name):
|
||||
target_path = os.path.join(root, f)
|
||||
file_ext = f.split('.')[-1]
|
||||
break
|
||||
|
||||
if action == "stop":
|
||||
# Bij stop: probeer container te stoppen als die er is
|
||||
if matching:
|
||||
for c in matching:
|
||||
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
|
||||
return {"status": "stopped", "count": len(matching)}
|
||||
return {"status": "already stopped or not found"}
|
||||
|
||||
if action == "start":
|
||||
# TYPE 1: YAML (Kube Play)
|
||||
if file_ext == "yaml":
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
res = SESSION.post(url, data=yaml_content)
|
||||
return res.json()
|
||||
|
||||
# TYPE 2: QUADLET (.container)
|
||||
# Omdat Quadlets via systemd gaan en de container weg is,
|
||||
# is de enige 'schone' weg via de API een 'prune' en herstart triggeren
|
||||
# Voor nu proberen we de container her-aanmaak te forceren:
|
||||
if file_ext == "container":
|
||||
# Bij Quadlets op Debian is het vaak het beste om een 'generate'
|
||||
# of een specifieke systeem-trigger te doen.
|
||||
# TEST: We sturen een bericht dat we dit via een shell-commando moeten doen
|
||||
return {"status": "error", "message": "Quadlets (.container) vereisen systemctl start op de host."}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
"""Starten/Stoppen via de API."""
|
||||
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
|
||||
if action == "start":
|
||||
# Hier zouden we 'podman kube play' logica doen
|
||||
pass
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
|
||||
SESSION.post(url)
|
||||
return {"status": "request sent"}
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,238 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
pod_name = f"pod{name}"
|
||||
|
||||
if action == "stop":
|
||||
# Probeer de hele pod te stoppen
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
|
||||
if res.status_code == 204 or res.status_code == 200:
|
||||
return {"status": "stopped", "target": pod_name}
|
||||
|
||||
# Fallback: als het geen pod is, stop individuele containers
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
matching = [c for c in api_containers if name in c['Names'][0]]
|
||||
for c in matching:
|
||||
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
|
||||
return {"status": "stop-attempted", "count": len(matching)}
|
||||
|
||||
if action == "start":
|
||||
# Altijd eerst geforceerd de oude pod verwijderen
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
|
||||
|
||||
target_path = None
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
res = SESSION.post(url, data=yaml_content)
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
"""Starten/Stoppen via de API."""
|
||||
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
|
||||
if action == "start":
|
||||
# Hier zouden we 'podman kube play' logica doen
|
||||
pass
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
|
||||
SESSION.post(url)
|
||||
return {"status": "request sent"}
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/api/files/tree")
|
||||
def get_file_tree():
|
||||
"""Geeft een lijst van alle mappen en bestanden in de workloads map."""
|
||||
tree = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
relative_path = os.relpath(root, WORKLOADS_DIR)
|
||||
if relative_path == ".":
|
||||
relative_path = ""
|
||||
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": [f for f in files if f.endswith(('.yaml', '.container'))]
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/api/files/read")
|
||||
def read_file(path: str):
|
||||
"""Leest de inhoud van een specifiek bestand op."""
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(str(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403, detail="Toegang geweigerd")
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
|
||||
|
||||
with open(full_path, 'r') as f:
|
||||
return {"content": f.read()}
|
||||
|
||||
@app.post("/api/files/save")
|
||||
def save_file(path: str, data: FileContent):
|
||||
"""Slaat de inhoud op naar een bestand (overschrijft bestaand)."""
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(str(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403, detail="Toegang geweigerd")
|
||||
|
||||
# Maak mappen aan als ze niet bestaan
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
with open(full_path, 'w') as f:
|
||||
f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/api/files/delete")
|
||||
def delete_file(path: str):
|
||||
"""Verwijdert een bestand."""
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(str(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403, detail="Toegang geweigerd")
|
||||
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted"}
|
||||
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,248 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
pod_name = f"pod{name}"
|
||||
|
||||
if action == "stop":
|
||||
# Probeer de hele pod te stoppen
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
|
||||
if res.status_code == 204 or res.status_code == 200:
|
||||
return {"status": "stopped", "target": pod_name}
|
||||
|
||||
# Fallback: als het geen pod is, stop individuele containers
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
matching = [c for c in api_containers if name in c['Names'][0]]
|
||||
for c in matching:
|
||||
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
|
||||
return {"status": "stop-attempted", "count": len(matching)}
|
||||
|
||||
if action == "start":
|
||||
# Altijd eerst geforceerd de oude pod verwijderen
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
|
||||
|
||||
target_path = None
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
res = SESSION.post(url, data=yaml_content)
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
#@app.delete("/files/delete")
|
||||
#def delete_file_managed(path: str):
|
||||
# full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
# if os.path.exists(full_path):
|
||||
# os.remove(full_path)
|
||||
# return {"status": "deleted"}
|
||||
# raise HTTPException(status_code=404)
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
"""Maakt een nieuwe map aan binnen de workloads map."""
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,256 +0,0 @@
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
pod_name = f"pod{name}"
|
||||
|
||||
if action == "stop":
|
||||
# Probeer de hele pod te stoppen
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
|
||||
if res.status_code == 204 or res.status_code == 200:
|
||||
return {"status": "stopped", "target": pod_name}
|
||||
|
||||
# Fallback: als het geen pod is, stop individuele containers
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
matching = [c for c in api_containers if name in c['Names'][0]]
|
||||
for c in matching:
|
||||
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
|
||||
return {"status": "stop-attempted", "count": len(matching)}
|
||||
|
||||
if action == "start":
|
||||
# Altijd eerst geforceerd de oude pod verwijderen
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
|
||||
|
||||
target_path = None
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
url = f"{PODMAN_API_BASE}/libpod/kube/play"
|
||||
res = SESSION.post(url, data=yaml_content)
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,410 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
# JSON API
|
||||
#@app.post("/api/daemon-reload")
|
||||
#def api_daemon_reload():
|
||||
# code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
# return jsonify({"cmd": ["systemctl", "--user", "daemon-reload"], "exit": code, "output": out})
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,425 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,488 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# helper blok
|
||||
def podname_to_systemd_unit(podname: str) -> str | None:
|
||||
"""
|
||||
Jouw conventie:
|
||||
podmediaserver -> mediaserver.service
|
||||
"""
|
||||
if podname.startswith("pod") and len(podname) > 3:
|
||||
base = podname[3:]
|
||||
return f"{base}.service"
|
||||
return None
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
"""
|
||||
Probeert systemd --user <action> <unit>.
|
||||
Return dict met exit/output + unit, of None als geen mapping.
|
||||
"""
|
||||
unit = podname_to_systemd_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
|
||||
code, out = run(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# einde helper blok
|
||||
|
||||
# endpoint blok
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
"""
|
||||
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
|
||||
anders fallback naar bestaande Podman take_action().
|
||||
"""
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) Probeer systemd (alleen als mapping bestaat)
|
||||
sys_res = try_systemd_pod_action(action, podname)
|
||||
if sys_res is not None:
|
||||
if sys_res["exit"] == 0:
|
||||
return sys_res
|
||||
|
||||
# systemd faalde -> fallback naar Podman
|
||||
return {
|
||||
"method": "systemd_then_podman",
|
||||
"systemd": sys_res,
|
||||
"podman": take_action(action, podname),
|
||||
"note": "systemd failed, fell back to podman",
|
||||
}
|
||||
|
||||
# 2) Geen mapping -> fallback naar Podman
|
||||
return {"method": "podman", "result": take_action(action, podname)}
|
||||
|
||||
# einde endpointblok
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,488 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# helper blok
|
||||
def podname_to_systemd_unit(podname: str) -> str | None:
|
||||
"""
|
||||
Jouw conventie:
|
||||
podmediaserver -> mediaserver.service
|
||||
"""
|
||||
if podname.startswith("pod") and len(podname) > 3:
|
||||
base = podname[3:]
|
||||
return f"{base}.service"
|
||||
return None
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
"""
|
||||
Probeert systemd --user <action> <unit>.
|
||||
Return dict met exit/output + unit, of None als geen mapping.
|
||||
"""
|
||||
unit = podname_to_systemd_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
|
||||
code, out = run(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# einde helper blok
|
||||
|
||||
# endpoint blok
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
"""
|
||||
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
|
||||
anders fallback naar bestaande Podman take_action().
|
||||
"""
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) Probeer systemd (alleen als mapping bestaat)
|
||||
sys_res = try_systemd_pod_action(action, podname)
|
||||
if sys_res is not None:
|
||||
if sys_res["exit"] == 0:
|
||||
return sys_res
|
||||
|
||||
# systemd faalde -> fallback naar Podman
|
||||
return {
|
||||
"method": "systemd_then_podman",
|
||||
"systemd": sys_res,
|
||||
"podman": take_action(action, podname),
|
||||
"note": "systemd failed, fell back to podman",
|
||||
}
|
||||
|
||||
# 2) Geen mapping -> fallback naar Podman
|
||||
return {"method": "podman", "result": take_action(action, podname)}
|
||||
|
||||
# einde endpointblok
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,547 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
"""
|
||||
Combineer:
|
||||
A) pods die echt bestaan (Podman API)
|
||||
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
|
||||
"""
|
||||
# A) echte pods
|
||||
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
|
||||
by_name = {}
|
||||
for p in api_pods:
|
||||
name = p.get("Name") or p.get("name")
|
||||
if not name:
|
||||
continue
|
||||
by_name[name] = {
|
||||
"Name": name,
|
||||
"Status": p.get("Status") or p.get("status") or "unknown",
|
||||
"Source": "podman",
|
||||
"Unit": None
|
||||
}
|
||||
|
||||
# B) gedefinieerde pods (flexibel): scan WORKLOADS_DIR naar *.yaml/*.kube
|
||||
# Heuristiek: basename "mediaserver.yaml" => unit "mediaserver.service" => pod "podmediaserver"
|
||||
# (jij gaf aan: systemd maakt naam met "pod" prefix)
|
||||
managed = set()
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for fn in files:
|
||||
if not fn.endswith((".yaml", ".kube")):
|
||||
continue
|
||||
base = os.path.splitext(fn)[0] # mediaserver
|
||||
pod_name = f"pod{base}" # podmediaserver
|
||||
unit_name = f"{base}.service" # mediaserver.service
|
||||
managed.add((pod_name, unit_name))
|
||||
|
||||
for pod_name, unit_name in sorted(managed):
|
||||
if pod_name not in by_name:
|
||||
# bestaat niet als pod => toch tonen
|
||||
# status halen uit systemd
|
||||
code, out = run(["systemctl", "--user", "is-active", unit_name])
|
||||
active = (out.strip() if out else "unknown")
|
||||
status = "Running" if active == "active" else "Stopped (defined)"
|
||||
by_name[pod_name] = {
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Source": "systemd",
|
||||
"Unit": unit_name
|
||||
}
|
||||
else:
|
||||
# bestaat wel: verrijk met unit-info (handig voor UI)
|
||||
by_name[pod_name]["Unit"] = unit_name
|
||||
|
||||
return list(by_name.values())
|
||||
|
||||
|
||||
|
||||
|
||||
# helper blok
|
||||
def podname_to_systemd_unit(podname: str) -> str | None:
|
||||
"""
|
||||
Jouw conventie:
|
||||
podmediaserver -> mediaserver.service
|
||||
"""
|
||||
if podname.startswith("pod") and len(podname) > 3:
|
||||
base = podname[3:]
|
||||
return f"{base}.service"
|
||||
return None
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
"""
|
||||
Probeert systemd --user <action> <unit>.
|
||||
Return dict met exit/output + unit, of None als geen mapping.
|
||||
"""
|
||||
unit = podname_to_systemd_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
|
||||
code, out = run(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# einde helper blok
|
||||
|
||||
# endpoint blok
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
"""
|
||||
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
|
||||
anders fallback naar bestaande Podman take_action().
|
||||
"""
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
|
||||
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
|
||||
|
||||
|
||||
# 1) Probeer systemd (alleen als mapping bestaat)
|
||||
sys_res = try_systemd_pod_action(action, podname)
|
||||
if sys_res is not None:
|
||||
if sys_res["exit"] == 0:
|
||||
return sys_res
|
||||
|
||||
# systemd faalde -> fallback naar Podman
|
||||
return {
|
||||
"method": "systemd_then_podman",
|
||||
"systemd": sys_res,
|
||||
"podman": take_action(action, base_name),
|
||||
"note": "systemd failed, fell back to podman",
|
||||
}
|
||||
|
||||
# 2) Geen mapping -> fallback naar Podman
|
||||
return {"method": "podman", "result": take_action(action, base_name)}
|
||||
|
||||
# einde endpointblok
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,654 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
"""
|
||||
Combineer:
|
||||
A) pods die echt bestaan (Podman API)
|
||||
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
|
||||
"""
|
||||
# A) echte pods
|
||||
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
|
||||
by_name = {}
|
||||
for p in api_pods:
|
||||
name = p.get("Name") or p.get("name")
|
||||
if not name:
|
||||
continue
|
||||
by_name[name] = {
|
||||
"Name": name,
|
||||
"Status": p.get("Status") or p.get("status") or "unknown",
|
||||
"Source": "podman",
|
||||
"Unit": None
|
||||
}
|
||||
|
||||
# B) gedefinieerde pods (flexibel): scan WORKLOADS_DIR naar *.yaml/*.kube
|
||||
# Heuristiek: basename "mediaserver.yaml" => unit "mediaserver.service" => pod "podmediaserver"
|
||||
# (jij gaf aan: systemd maakt naam met "pod" prefix)
|
||||
managed = set()
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for fn in files:
|
||||
if not fn.endswith((".yaml", ".kube")):
|
||||
continue
|
||||
base = os.path.splitext(fn)[0] # mediaserver
|
||||
pod_name = f"pod{base}" # podmediaserver
|
||||
unit_name = f"{base}.service" # mediaserver.service
|
||||
managed.add((pod_name, unit_name))
|
||||
|
||||
for pod_name, unit_name in sorted(managed):
|
||||
if pod_name not in by_name:
|
||||
# bestaat niet als pod => toch tonen
|
||||
# status halen uit systemd
|
||||
code, out = run(["systemctl", "--user", "is-active", unit_name])
|
||||
active = (out.strip() if out else "unknown")
|
||||
status = "Running" if active == "active" else "Stopped (defined)"
|
||||
by_name[pod_name] = {
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Source": "systemd",
|
||||
"Unit": unit_name
|
||||
}
|
||||
else:
|
||||
# bestaat wel: verrijk met unit-info (handig voor UI)
|
||||
by_name[pod_name]["Unit"] = unit_name
|
||||
|
||||
return list(by_name.values())
|
||||
|
||||
|
||||
|
||||
|
||||
# helper blok
|
||||
def podname_to_systemd_unit(podname: str) -> str | None:
|
||||
"""
|
||||
Jouw conventie:
|
||||
podmediaserver -> mediaserver.service
|
||||
"""
|
||||
if podname.startswith("pod") and len(podname) > 3:
|
||||
base = podname[3:]
|
||||
return f"{base}.service"
|
||||
return None
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
"""
|
||||
Probeert systemd --user <action> <unit>.
|
||||
Return dict met exit/output + unit, of None als geen mapping.
|
||||
"""
|
||||
unit = podname_to_systemd_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
|
||||
code, out = run(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
def find_defined_containers():
|
||||
"""
|
||||
Zoekt naar *.container onder WORKLOADS_DIR/systemd (recursief).
|
||||
Return: dict name -> relpath (bijv {"sonarr": "sonarr/sonarr.container"})
|
||||
"""
|
||||
base = os.path.join(WORKLOADS_DIR, "systemd")
|
||||
defined = {}
|
||||
if not os.path.isdir(base):
|
||||
return defined
|
||||
|
||||
for root, _, files in os.walk(base):
|
||||
for fn in files:
|
||||
if not fn.endswith(".container"):
|
||||
continue
|
||||
name = fn[:-len(".container")] # sonarr
|
||||
rel = os.path.relpath(os.path.join(root, fn), base)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
# einde helper blok
|
||||
|
||||
# endpoint blok
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
"""
|
||||
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
|
||||
anders fallback naar bestaande Podman take_action().
|
||||
"""
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
|
||||
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
|
||||
|
||||
|
||||
# 1) Probeer systemd (alleen als mapping bestaat)
|
||||
sys_res = try_systemd_pod_action(action, podname)
|
||||
if sys_res is not None:
|
||||
if sys_res["exit"] == 0:
|
||||
return sys_res
|
||||
|
||||
# systemd faalde -> fallback naar Podman
|
||||
return {
|
||||
"method": "systemd_then_podman",
|
||||
"systemd": sys_res,
|
||||
"podman": take_action(action, base_name),
|
||||
"note": "systemd failed, fell back to podman",
|
||||
}
|
||||
|
||||
# 2) Geen mapping -> fallback naar Podman
|
||||
return {"method": "podman", "result": take_action(action, base_name)}
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
# A) echte containers
|
||||
real = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
by_name = {}
|
||||
|
||||
for c in real:
|
||||
# jouw UI pakt meestal Names[0]
|
||||
names = c.get("Names") or []
|
||||
name = names[0] if isinstance(names, list) and names else (c.get("Name") or c.get("name"))
|
||||
if not name:
|
||||
continue
|
||||
# normalize: sommige APIs geven "/sonarr"
|
||||
name = name.lstrip("/")
|
||||
c["_dashboard_source"] = "podman"
|
||||
labels = c.get("Labels") or {}
|
||||
unit = labels.get("PODMAN_SYSTEMD_UNIT")
|
||||
if unit:
|
||||
c["_dashboard_source"] = "systemd(kube)"
|
||||
c["_dashboard_unit"] = unit
|
||||
if not c.get("Status"):
|
||||
c["Status"] = c.get("State") or c.get("status") or ""
|
||||
by_name[name] = c
|
||||
|
||||
# B) defined containers uit .container files
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in by_name:
|
||||
# container bestaat al, verrijk met systemd info
|
||||
by_name[name]["_dashboard_source"] = "podman+systemd"
|
||||
by_name[name]["_dashboard_unit"] = name
|
||||
by_name[name]["_dashboard_def_path"] = relpath
|
||||
continue
|
||||
|
||||
# bestaat niet -> voeg defined entry toe
|
||||
by_name[name] = {
|
||||
"Names": [name],
|
||||
"Status": "Stopped (defined)",
|
||||
"State": "defined",
|
||||
"Image": "",
|
||||
"PodName": "",
|
||||
"Ports": [],
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": name,
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
return list(by_name.values())
|
||||
|
||||
# einde endpointblok
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) Eerst: probeer Podman (voor bestaande containers)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
|
||||
# 200/204/304 zien we als succes (304 = "was al in die state")
|
||||
if res.status_code in (200, 204, 304):
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"POST {url}",
|
||||
"status_code": res.status_code,
|
||||
}
|
||||
|
||||
# 2) Als Podman faalt: check of dit een .container definition is => systemd fallback
|
||||
defined = find_defined_containers() # deze helper moet je ook hebben
|
||||
if name in defined:
|
||||
code, out = run(["systemctl", "--user", action, name])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": name,
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# 3) Anders: echte Podman error teruggeven
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"POST {url}",
|
||||
"status_code": res.status_code,
|
||||
"error": res.text,
|
||||
}, res.status_code
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,730 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
def _safe_workloads_path(rel_path: str) -> Path:
|
||||
"""
|
||||
rel_path is iets als: "systemd/demo-web"
|
||||
Zorgt dat je niet buiten WORKLOADS_DIR kunt komen (geen ../)
|
||||
"""
|
||||
base = Path(WORKLOADS_DIR).resolve()
|
||||
rel = (rel_path or "").lstrip("/")
|
||||
|
||||
# hard fence: alleen systemd subtree
|
||||
if rel == "systemd" or rel.startswith("systemd/"):
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
|
||||
|
||||
# geen systeemd root verwijderen
|
||||
if rel == "systemd":
|
||||
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
|
||||
|
||||
target = (base / rel).resolve()
|
||||
|
||||
# ensure target binnen base
|
||||
if not str(target).startswith(str(base) + "/"):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
return target
|
||||
|
||||
@app.delete("/files/rmdir")
|
||||
def files_rmdir(path: str = Query(..., description="Directory path under systemd/")):
|
||||
target = _safe_workloads_path(path)
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
# echte leeg-check (files én subdirs)
|
||||
entries = list(target.iterdir())
|
||||
if entries:
|
||||
# geef wat context terug
|
||||
files = [p.name for p in entries if p.is_file()]
|
||||
dirs = [p.name for p in entries if p.is_dir()]
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"error": "directory not empty", "files": files, "dirs": dirs}
|
||||
)
|
||||
|
||||
target.rmdir()
|
||||
return {"deleted": True, "path": path}
|
||||
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
"""
|
||||
Combineer:
|
||||
A) pods die echt bestaan (Podman API)
|
||||
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
|
||||
|
||||
Extra:
|
||||
- Voeg per pod een lijst "Containers" toe met container-namen.
|
||||
"""
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
containers_by_pod = {}
|
||||
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if not pod_name:
|
||||
continue
|
||||
|
||||
names = c.get("Names") or []
|
||||
if isinstance(names, list) and names:
|
||||
cname = names[0]
|
||||
else:
|
||||
cname = c.get("Name") or c.get("name") or ""
|
||||
|
||||
cname = (cname or "").lstrip("/")
|
||||
if not cname:
|
||||
continue
|
||||
|
||||
containers_by_pod.setdefault(pod_name, []).append(cname)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
|
||||
by_name = {}
|
||||
|
||||
for p in api_pods:
|
||||
name = p.get("Name") or p.get("name")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
by_name[name] = {
|
||||
"Name": name,
|
||||
"Status": p.get("Status") or p.get("status") or "unknown",
|
||||
"Source": "podman",
|
||||
"Unit": None,
|
||||
"Containers": sorted(containers_by_pod.get(name, [])),
|
||||
}
|
||||
|
||||
# 2) B) gedefinieerde pods: scan WORKLOADS_DIR naar *.yaml/*.kube
|
||||
managed = set()
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for fn in files:
|
||||
if not fn.endswith((".yaml", ".kube")):
|
||||
continue
|
||||
base = os.path.splitext(fn)[0] # mediaserver
|
||||
pod_name = f"pod{base}" # podmediaserver
|
||||
unit_name = f"{base}.service" # mediaserver.service
|
||||
managed.add((pod_name, unit_name))
|
||||
|
||||
for pod_name, unit_name in sorted(managed):
|
||||
if pod_name not in by_name:
|
||||
# bestaat niet als pod => toch tonen met systemd status
|
||||
code, out = run(["systemctl", "--user", "is-active", unit_name])
|
||||
active = (out.strip() if out else "unknown")
|
||||
status = "Running" if active == "active" else "Stopped (defined)"
|
||||
|
||||
by_name[pod_name] = {
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Source": "systemd",
|
||||
"Unit": unit_name,
|
||||
"Containers": sorted(containers_by_pod.get(pod_name, [])),
|
||||
}
|
||||
else:
|
||||
# bestaat wel: verrijk met unit-info (handig voor UI)
|
||||
by_name[pod_name]["Unit"] = unit_name
|
||||
|
||||
return list(by_name.values())
|
||||
|
||||
# helper blok
|
||||
def podname_to_systemd_unit(podname: str) -> str | None:
|
||||
"""
|
||||
Jouw conventie:
|
||||
podmediaserver -> mediaserver.service
|
||||
"""
|
||||
if podname.startswith("pod") and len(podname) > 3:
|
||||
base = podname[3:]
|
||||
return f"{base}.service"
|
||||
return None
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
"""
|
||||
Probeert systemd --user <action> <unit>.
|
||||
Return dict met exit/output + unit, of None als geen mapping.
|
||||
"""
|
||||
unit = podname_to_systemd_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
|
||||
code, out = run(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
def find_defined_containers():
|
||||
"""
|
||||
Zoekt naar *.container onder WORKLOADS_DIR/systemd (recursief).
|
||||
Return: dict name -> relpath (bijv {"sonarr": "sonarr/sonarr.container"})
|
||||
"""
|
||||
base = os.path.join(WORKLOADS_DIR, "systemd")
|
||||
defined = {}
|
||||
if not os.path.isdir(base):
|
||||
return defined
|
||||
|
||||
for root, _, files in os.walk(base):
|
||||
for fn in files:
|
||||
if not fn.endswith(".container"):
|
||||
continue
|
||||
name = fn[:-len(".container")] # sonarr
|
||||
rel = os.path.relpath(os.path.join(root, fn), base)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
# einde helper blok
|
||||
|
||||
# endpoint blok
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
"""
|
||||
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
|
||||
anders fallback naar bestaande Podman take_action().
|
||||
"""
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
|
||||
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
|
||||
|
||||
|
||||
# 1) Probeer systemd (alleen als mapping bestaat)
|
||||
sys_res = try_systemd_pod_action(action, podname)
|
||||
if sys_res is not None:
|
||||
if sys_res["exit"] == 0:
|
||||
return sys_res
|
||||
|
||||
# systemd faalde -> fallback naar Podman
|
||||
return {
|
||||
"method": "systemd_then_podman",
|
||||
"systemd": sys_res,
|
||||
"podman": take_action(action, base_name),
|
||||
"note": "systemd failed, fell back to podman",
|
||||
}
|
||||
|
||||
# 2) Geen mapping -> fallback naar Podman
|
||||
return {"method": "podman", "result": take_action(action, base_name)}
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
# A) echte containers
|
||||
real = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
by_name = {}
|
||||
|
||||
for c in real:
|
||||
# jouw UI pakt meestal Names[0]
|
||||
names = c.get("Names") or []
|
||||
name = names[0] if isinstance(names, list) and names else (c.get("Name") or c.get("name"))
|
||||
if not name:
|
||||
continue
|
||||
# normalize: sommige APIs geven "/sonarr"
|
||||
name = name.lstrip("/")
|
||||
c["_dashboard_source"] = "podman"
|
||||
labels = c.get("Labels") or {}
|
||||
unit = labels.get("PODMAN_SYSTEMD_UNIT")
|
||||
if unit:
|
||||
c["_dashboard_source"] = "systemd(kube)"
|
||||
c["_dashboard_unit"] = unit
|
||||
if not c.get("Status"):
|
||||
c["Status"] = c.get("State") or c.get("status") or ""
|
||||
by_name[name] = c
|
||||
|
||||
# B) defined containers uit .container files
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in by_name:
|
||||
# container bestaat al, verrijk met systemd info
|
||||
by_name[name]["_dashboard_source"] = "podman+systemd"
|
||||
by_name[name]["_dashboard_unit"] = name
|
||||
by_name[name]["_dashboard_def_path"] = relpath
|
||||
continue
|
||||
|
||||
# bestaat niet -> voeg defined entry toe
|
||||
by_name[name] = {
|
||||
"Names": [name],
|
||||
"Status": "Stopped (defined)",
|
||||
"State": "defined",
|
||||
"Image": "",
|
||||
"PodName": "",
|
||||
"Ports": [],
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": name,
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
return list(by_name.values())
|
||||
|
||||
# einde endpointblok
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) Eerst: probeer Podman (voor bestaande containers)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
|
||||
# 200/204/304 zien we als succes (304 = "was al in die state")
|
||||
if res.status_code in (200, 204, 304):
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"POST {url}",
|
||||
"status_code": res.status_code,
|
||||
}
|
||||
|
||||
# 2) Als Podman faalt: check of dit een .container definition is => systemd fallback
|
||||
defined = find_defined_containers() # deze helper moet je ook hebben
|
||||
if name in defined:
|
||||
code, out = run(["systemctl", "--user", action, name])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": name,
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# 3) Anders: echte Podman error teruggeven
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"POST {url}",
|
||||
"status_code": res.status_code,
|
||||
"error": res.text,
|
||||
}, res.status_code
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
return {"allow_mode": allow_mode, "units": sorted(allowed)}
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,644 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = f"{name}.service" if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
# derive pod name from filename (legacy convention)
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = f"{pod_name}.service"
|
||||
|
||||
if pod_name not in by_name:
|
||||
# bestaat niet als pod => toch tonen met systemd status
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
# Derive a likely systemd unit from pod name (legacy convention).
|
||||
if not podname:
|
||||
return None
|
||||
# e.g. podmediaserver -> podmediaserver.service
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
podman = _podman_post(url).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
return {"method": "podman", "result": _podman_post(url).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
# ensure Status exists (legacy behavior)
|
||||
if "Status" not in c:
|
||||
c["Status"] = c.get("State", "")
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) defined containers from systemd/*.container
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
dashboard.append({
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
})
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) Eerst: probeer Podman (voor bestaande containers)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = _podman_post(url)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
# Podman error path (tuple-return style)
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# 2) Als Podman faalt: kijk of het een defined container is
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
# fallback
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append({
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,694 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
podman = _podman_post(url).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
return {"method": "podman", "result": _podman_post(url).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) defined containers from systemd/*.container
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# HOTFIX 3.1 FIX 2: fill Status from systemd is-active, keep keyset identical
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# HOTFIX 3.2:
|
||||
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
# systemd failed -> fall back to podman (unchanged podman result behavior below)
|
||||
|
||||
# Podman path (primary for non-defined, fallback for defined if systemd failed)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = _podman_post(url)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# If podman did not succeed and this is defined, we keep legacy behavior:
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,699 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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 = safe_join(WORKLOADS_DIR, 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
podman = _podman_post(url).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
return {"method": "podman", "result": _podman_post(url).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# HOTFIX 3.2:
|
||||
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
# systemd failed -> fall back to podman (unchanged podman result behavior below)
|
||||
|
||||
# Podman path (primary for non-defined, fallback for defined if systemd failed)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = _podman_post(url)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# If podman did not succeed and this is defined, we keep legacy behavior:
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,705 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
|
||||
# --- 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
podman = _podman_post(url).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
return {"method": "podman", "result": _podman_post(url).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# HOTFIX 3.2:
|
||||
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
# systemd failed -> fall back to podman (unchanged podman result behavior below)
|
||||
|
||||
# Podman path (primary for non-defined, fallback for defined if systemd failed)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = _podman_post(url)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# If podman did not succeed and this is defined, we keep legacy behavior:
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,420 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
# --- MODELS ---
|
||||
class FileContent(BaseModel):
|
||||
content: str
|
||||
|
||||
class FileCreate(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
@app.get("/workloads")
|
||||
def list_workloads():
|
||||
yaml_files = []
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for file in files:
|
||||
if file.endswith((".yaml", ".kube")):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
|
||||
yaml_files.append(rel_path)
|
||||
return {"workloads": sorted(yaml_files)}
|
||||
|
||||
@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(status_code=404)
|
||||
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
|
||||
|
||||
@app.post("/workloads/save-file")
|
||||
def save_file(file_data: FileCreate):
|
||||
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, 'w') as f: f.write(file_data.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 SESSION.post(url, data=yaml_content).json()
|
||||
|
||||
# --- PODS ---
|
||||
@app.get("/pods")
|
||||
def list_pods():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
|
||||
possible_names = [f"pod{name}", name]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
# 204 = Succes, 304 = Draait al (ook succes)
|
||||
if res.status_code in [204, 304, 200]:
|
||||
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
|
||||
return {"status": "started", "method": "direct_pod_start", "target": target}
|
||||
|
||||
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
|
||||
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
|
||||
target_path = None
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.startswith(name) and f.endswith('.yaml'):
|
||||
target_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
|
||||
# Dan forceren we een delete en een herstart.
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in [204, 304, 200]:
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not_found_or_stopped"}
|
||||
|
||||
return {"status": "unknown_action"}
|
||||
|
||||
# --- CONTAINERS ---
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return SESSION.get(url).json()
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
# Podman API pad: /libpod/containers/{name}/{action}
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = SESSION.post(url)
|
||||
return {"status": "success", "code": res.status_code}
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
try:
|
||||
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
|
||||
except:
|
||||
api_containers = []
|
||||
|
||||
dashboard = {}
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
if f.endswith(('.yaml', '.kube', '.container')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
|
||||
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
|
||||
name_base = f.split('.')[0]
|
||||
|
||||
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
|
||||
if name_base not in dashboard:
|
||||
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
|
||||
matching = [c for c in api_containers if name_base in c['Names'][0]]
|
||||
|
||||
status = "stopped"
|
||||
ip = "-"
|
||||
container_count = len(matching)
|
||||
|
||||
if matching:
|
||||
# Als er minstens één container draait, is de status 'running'
|
||||
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
|
||||
# Pak IP van de eerste container die een IP heeft
|
||||
for c in matching:
|
||||
nets = c.get('Networks', {})
|
||||
first_net = next(iter(nets.values()), {}) if nets else {}
|
||||
if first_net.get('IPAddress'):
|
||||
ip = first_net['IPAddress']
|
||||
break
|
||||
|
||||
dashboard[name_base] = {
|
||||
"name": name_base,
|
||||
"path": rel_path,
|
||||
"status": status,
|
||||
"ip": ip,
|
||||
"containers": container_count
|
||||
}
|
||||
|
||||
return list(dashboard.values())
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check bestanden op schijf
|
||||
try:
|
||||
files = os.listdir(WORKLOADS_DIR)
|
||||
except Exception as e:
|
||||
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
api_containers = api_res.json()
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": files,
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
|
||||
}
|
||||
|
||||
@app.get("/files/tree")
|
||||
def get_file_tree():
|
||||
tree = []
|
||||
# os.walk gaat door alle mappen heen
|
||||
for root, dirs, files in os.walk(WORKLOADS_DIR):
|
||||
# JUISTE SYNTAX: os.path.relpath
|
||||
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
|
||||
|
||||
# Filter alleen de bestanden die we willen zien
|
||||
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
|
||||
|
||||
# Voeg de map toe aan de lijst
|
||||
tree.append({
|
||||
"path": relative_path,
|
||||
"dirs": dirs,
|
||||
"files": valid_files
|
||||
})
|
||||
return tree
|
||||
|
||||
@app.get("/files/read")
|
||||
def read_file_managed(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
if not os.path.exists(full_path): raise HTTPException(status_code=404)
|
||||
with open(full_path, 'r') as f: return {"content": f.read()}
|
||||
|
||||
@app.post("/files/save")
|
||||
def save_file_managed(path: str, data: FileContent):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w') as f: f.write(data.content)
|
||||
return {"status": "success", "path": path}
|
||||
|
||||
@app.delete("/files/delete")
|
||||
def delete_item(path: str):
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
# Verwijder een lege map
|
||||
os.rmdir(full_path)
|
||||
else:
|
||||
# Verwijder een bestand
|
||||
os.remove(full_path)
|
||||
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
|
||||
except OSError as e:
|
||||
# Dit gebeurt o.a. als een map niet leeg is
|
||||
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
|
||||
|
||||
@app.post("/files/mkdir")
|
||||
def create_directory(path: str):
|
||||
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
|
||||
if not path.startswith("systemd"):
|
||||
path = os.path.join("systemd", path)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
|
||||
# Veiligheidscheck blijft gelijk
|
||||
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
|
||||
raise HTTPException(status_code=403)
|
||||
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
return {"status": "directory created", "path": path}
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": res.text}
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
return res.json()
|
||||
|
||||
# SYSTEMCTL FUNCTIONS
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
return r.returncode, out.strip()
|
||||
|
||||
def read_allowlist():
|
||||
"""
|
||||
Return: (set_of_units, allow_mode_bool)
|
||||
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
|
||||
"""
|
||||
if not os.path.exists(ALLOWLIST_FILE):
|
||||
return set(), False
|
||||
|
||||
allowed = set()
|
||||
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# minimale sanity: alleen .service
|
||||
if line.endswith(".service"):
|
||||
allowed.add(line)
|
||||
|
||||
return allowed, (len(allowed) > 0)
|
||||
|
||||
def list_unit_files():
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode:
|
||||
# whitelist = bron van waarheid
|
||||
return sorted(allowed)
|
||||
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = run(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
|
||||
if code != 0 or "Failed to get unit file state" in enabled_out:
|
||||
enabled = "unknown"
|
||||
else:
|
||||
enabled = enabled_out
|
||||
|
||||
return active, enabled
|
||||
|
||||
def get_units_for_ui():
|
||||
all_units = list_unit_files()
|
||||
_, allow_mode = read_allowlist()
|
||||
|
||||
result = []
|
||||
for u in all_units:
|
||||
active, enabled = unit_state(u)
|
||||
result.append({"name": u, "active": active, "enabled": enabled})
|
||||
return result, allow_mode
|
||||
|
||||
def assert_allowed(unit):
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
abort(403, description="Unit not allowed by allowlist")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=None,
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = run(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
# allowlist check
|
||||
allowed, allow_mode = read_allowlist()
|
||||
if allow_mode and unit not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def action_form():
|
||||
unit = request.form.get("unit", "")
|
||||
action = request.form.get("action", "")
|
||||
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return "Invalid action", 400
|
||||
|
||||
assert_allowed(unit)
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
|
||||
units, allow_mode = get_units_for_ui()
|
||||
return render_template_string(
|
||||
HTML,
|
||||
units=units,
|
||||
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
|
||||
allowfile=ALLOWLIST_FILE,
|
||||
allow_mode=allow_mode
|
||||
)
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def api_action(action, unit):
|
||||
if action not in {"status", "start", "stop", "restart"}:
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
assert_allowed(unit)
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = run(cmd)
|
||||
return jsonify({"cmd": cmd, "exit": code, "output": out})
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,709 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
def _run_systemctl_action(action: str, unit: str):
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
return _systemctl(cmd)
|
||||
|
||||
|
||||
# --- 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
podman = _podman_post(url).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
|
||||
return {"method": "podman", "result": _podman_post(url).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# HOTFIX 3.2:
|
||||
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
# systemd failed -> fall back to podman (unchanged podman result behavior below)
|
||||
|
||||
# Podman path (primary for non-defined, fallback for defined if systemd failed)
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
res = _podman_post(url)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# If podman did not succeed and this is defined, we keep legacy behavior:
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,713 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_action_post(kind: str, name: str, action: str):
|
||||
if kind == "pods":
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
return _podman_post(url)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
def _run_systemctl_action(action: str, unit: str):
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
return _systemctl(cmd)
|
||||
|
||||
|
||||
# --- 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# 1) try systemd path
|
||||
systemd_res = try_systemd_pod_action(action, podname)
|
||||
if systemd_res and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
|
||||
# 2) if systemd tried but failed, fall back
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
# Podman pod action endpoint
|
||||
podman = _podman_action_post("pods", podname, action).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
|
||||
# 3) direct podman
|
||||
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
# HOTFIX 3.2:
|
||||
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
|
||||
defined = find_defined_containers()
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
# systemd failed -> fall back to podman (unchanged podman result behavior below)
|
||||
|
||||
# Podman path (primary for non-defined, fallback for defined if systemd failed)
|
||||
res = _podman_action_post("containers", name, action)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
# If podman did not succeed and this is defined, we keep legacy behavior:
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,724 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_action_post(kind: str, name: str, action: str):
|
||||
if kind == "pods":
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
return _podman_post(url)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
def _run_systemctl_action(action: str, unit: str):
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
return _systemctl(cmd)
|
||||
|
||||
|
||||
# --- 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def _systemd_then_podman(action_kind: str, action: str, name: str, systemd_callable, podman_callable):
|
||||
systemd_res = systemd_callable()
|
||||
if systemd_res is not None:
|
||||
if isinstance(systemd_res, dict) and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
return podman_callable(systemd_res)
|
||||
return podman_callable(None)
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
def _systemd_call():
|
||||
return try_systemd_pod_action(action, podname)
|
||||
|
||||
def _podman_call(systemd_res):
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
podman = _podman_action_post("pods", podname, action).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
|
||||
|
||||
return _systemd_then_podman("pods", action, podname, _systemd_call, _podman_call)
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
defined = find_defined_containers()
|
||||
_sys = {"code": None, "out": None}
|
||||
|
||||
def _systemd_call():
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
_sys["code"] = code
|
||||
_sys["out"] = out
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
return {"exit": code, "output": out}
|
||||
return None
|
||||
|
||||
def _podman_call(systemd_res):
|
||||
res = _podman_action_post("containers", name, action)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": _sys["code"],
|
||||
"output": _sys["out"],
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
return _systemd_then_podman("containers", action, name, _systemd_call, _podman_call)
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,724 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import requests_unixsocket
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
|
||||
SESSION = requests_unixsocket.Session()
|
||||
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
|
||||
WORKLOADS_DIR = "/app/workloads"
|
||||
|
||||
|
||||
# --- ADAPTERS (contract-neutral helpers) ---
|
||||
# Centralize Podman socket and systemctl invocation.
|
||||
# MUST NOT change endpoint outputs, status codes, or side-effects.
|
||||
|
||||
def _podman_get_json(url: str):
|
||||
return SESSION.get(url).json()
|
||||
|
||||
def _podman_get_text(url: str) -> str:
|
||||
return SESSION.get(url).text
|
||||
|
||||
def _podman_post(url: str, **kwargs):
|
||||
return SESSION.post(url, **kwargs)
|
||||
|
||||
def _podman_action_post(kind: str, name: str, action: str):
|
||||
if kind == "pods":
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
|
||||
else:
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
|
||||
return _podman_post(url)
|
||||
|
||||
def _podman_delete(url: str):
|
||||
return SESSION.delete(url)
|
||||
|
||||
def _systemctl(cmd):
|
||||
# Proxy to existing run() to avoid behavioral changes.
|
||||
return run(cmd)
|
||||
|
||||
def _run_systemctl_action(action: str, unit: str):
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
return _systemctl(cmd)
|
||||
|
||||
|
||||
# --- 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():
|
||||
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
|
||||
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/actions/{action}/{name}")
|
||||
def take_action(action: str, name: str):
|
||||
# Legacy endpoint (keep behavior)
|
||||
possible_names = [name, f"pod{name}", f"pod-{name}"]
|
||||
|
||||
if action == "start":
|
||||
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "started", "target": target, "method": "direct"}
|
||||
|
||||
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
|
||||
target_path = None
|
||||
for ext in (".yaml", ".yml"):
|
||||
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
|
||||
if os.path.exists(cand):
|
||||
target_path = cand
|
||||
break
|
||||
|
||||
if target_path:
|
||||
with open(target_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
|
||||
# SPECIALE CASE: Pod bestaat al, forceer dan restart
|
||||
if res.status_code == 500 and "already exists" in res.text:
|
||||
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
|
||||
for target in possible_names:
|
||||
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
|
||||
# Probeer het nu opnieuw
|
||||
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
|
||||
return retry_res.json()
|
||||
|
||||
return res.json()
|
||||
|
||||
return {"status": "unknown", "method": "no_yaml_found"}
|
||||
|
||||
if action == "stop":
|
||||
for target in possible_names:
|
||||
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
|
||||
if res.status_code in (200, 204):
|
||||
return {"status": "stopped", "target": target}
|
||||
return {"status": "not found"}
|
||||
|
||||
return {"status": "unknown"}
|
||||
|
||||
|
||||
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
|
||||
|
||||
def _build_pod_to_containers_map(containers: list):
|
||||
# preserves original order of containers processing; no sorting added
|
||||
pod_to_containers = {}
|
||||
for c in containers:
|
||||
pod_name = c.get("PodName") or ""
|
||||
if pod_name:
|
||||
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
|
||||
return pod_to_containers
|
||||
|
||||
|
||||
def _map_pod_to_unit(podname: str) -> str | None:
|
||||
"""
|
||||
HOTFIX 3.1 FIX 1:
|
||||
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
|
||||
Else: <podname>.service
|
||||
"""
|
||||
if not podname:
|
||||
return None
|
||||
if podname.startswith("pod"):
|
||||
return f"{podname[3:]}.service"
|
||||
return f"{podname}.service"
|
||||
|
||||
|
||||
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
|
||||
# preserves original api_pods iteration order
|
||||
for p in api_pods:
|
||||
name = p.get("Name")
|
||||
status = p.get("Status", "unknown")
|
||||
unit = _map_pod_to_unit(name) if name else ""
|
||||
dashboard.append({
|
||||
"Name": name,
|
||||
"Status": status,
|
||||
"Containers": pod_to_containers.get(name, []),
|
||||
"Unit": unit,
|
||||
"Source": "podman",
|
||||
})
|
||||
|
||||
|
||||
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
|
||||
# preserves original os.walk order and file iteration order
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.endswith((".yaml", ".yml")):
|
||||
base = os.path.splitext(os.path.basename(f))[0]
|
||||
pod_name = f"pod{base}"
|
||||
unit_name = _map_pod_to_unit(pod_name)
|
||||
|
||||
if pod_name not in by_name:
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
|
||||
status = (out or "").strip() or ("active" if code == 0 else "inactive")
|
||||
dashboard.append({
|
||||
"Name": pod_name,
|
||||
"Status": status,
|
||||
"Containers": [],
|
||||
"Unit": unit_name,
|
||||
"Source": "systemd",
|
||||
})
|
||||
|
||||
|
||||
def _ensure_container_status_field(container: dict):
|
||||
# keep exact existing defaulting behavior
|
||||
if "Status" not in container:
|
||||
container["Status"] = container.get("State", "")
|
||||
|
||||
|
||||
def _make_defined_container_dashboard_row(name: str, relpath: str):
|
||||
# keep exact key set and default values as before
|
||||
return {
|
||||
"Names": [name],
|
||||
"Image": "",
|
||||
"State": "",
|
||||
"Status": "",
|
||||
"Ports": [],
|
||||
"PodName": "",
|
||||
"_dashboard_source": "systemd",
|
||||
"_dashboard_unit": f"{name}.service",
|
||||
"_dashboard_def_path": relpath,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_dashboard_item_from_container(c: dict):
|
||||
# Keep exact keys & defaults as before
|
||||
return {
|
||||
"name": (c.get("Names") or ["?"])[0],
|
||||
"status": c.get("Status") or c.get("State") or "",
|
||||
"path": "",
|
||||
"ip": "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/pods-dashboard")
|
||||
def pods_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# 0) Bouw mapping: pod_name -> [container_names...]
|
||||
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
pod_to_containers = _build_pod_to_containers_map(containers)
|
||||
|
||||
# 1) A) echte pods
|
||||
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
|
||||
by_name = {p.get("Name"): p for p in api_pods}
|
||||
|
||||
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
|
||||
|
||||
# 1) B) defined pods via workloads scan
|
||||
# Based on YAML files in WORKLOADS_DIR; show even if not running.
|
||||
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
def _systemd_then_podman(systemd_callable, podman_callable):
|
||||
systemd_res = systemd_callable()
|
||||
if systemd_res is not None:
|
||||
if isinstance(systemd_res, dict) and systemd_res.get("exit", 1) == 0:
|
||||
return systemd_res
|
||||
return podman_callable(systemd_res)
|
||||
return podman_callable(None)
|
||||
|
||||
|
||||
def try_systemd_pod_action(action: str, podname: str):
|
||||
# If systemd unit exists/allowed, prefer it.
|
||||
unit = _map_pod_to_unit(podname)
|
||||
if not unit:
|
||||
return None
|
||||
code, out = _systemctl(["systemctl", "--user", action, unit])
|
||||
return {
|
||||
"method": "systemd",
|
||||
"pod": podname,
|
||||
"unit": unit,
|
||||
"cmd": f"systemctl --user {action} {unit}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/pods/actions/{action}/{podname}")
|
||||
def pod_action_prefer_systemd(action: str, podname: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
def _systemd_call():
|
||||
return try_systemd_pod_action(action, podname)
|
||||
|
||||
def _podman_call(systemd_res):
|
||||
if systemd_res:
|
||||
note = "systemd failed; falling back to podman"
|
||||
podman = _podman_action_post("pods", podname, action).json()
|
||||
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
|
||||
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
|
||||
|
||||
return _systemd_then_podman(_systemd_call, _podman_call)
|
||||
|
||||
|
||||
def find_defined_containers():
|
||||
defined = {}
|
||||
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
|
||||
for f in files:
|
||||
if f.endswith(".container"):
|
||||
name = os.path.splitext(f)[0]
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, WORKLOADS_DIR)
|
||||
defined[name] = rel
|
||||
return defined
|
||||
|
||||
|
||||
@app.get("/containers-dashboard")
|
||||
def containers_dashboard():
|
||||
dashboard = []
|
||||
|
||||
# A) echte containers (UNCHANGED)
|
||||
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
_ensure_container_status_field(c)
|
||||
c["_dashboard_source"] = "podman"
|
||||
dashboard.append(c)
|
||||
|
||||
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
|
||||
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
|
||||
|
||||
# C) defined containers from systemd/*.container (skip duplicates)
|
||||
defined = find_defined_containers()
|
||||
for name, relpath in defined.items():
|
||||
if name in runtime_names:
|
||||
continue
|
||||
row = _make_defined_container_dashboard_row(name, relpath)
|
||||
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
|
||||
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
return dashboard
|
||||
|
||||
|
||||
@app.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
|
||||
return _podman_get_json(url)
|
||||
|
||||
|
||||
@app.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
defined = find_defined_containers()
|
||||
_sys = {"code": None, "out": None}
|
||||
|
||||
def _systemd_call():
|
||||
if name in defined:
|
||||
code, out = _systemctl(["systemctl", "--user", action, name])
|
||||
_sys["code"] = code
|
||||
_sys["out"] = out
|
||||
if code == 0:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
return {"exit": code, "output": out}
|
||||
return None
|
||||
|
||||
def _podman_call(systemd_res):
|
||||
res = _podman_action_post("containers", name, action)
|
||||
if res.status_code in (200, 204):
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
if res.status_code >= 400:
|
||||
return {
|
||||
"method": "podman",
|
||||
"name": name,
|
||||
"cmd": f"podman {action} {name}",
|
||||
"status_code": res.status_code,
|
||||
"error": getattr(res, "text", "") or "",
|
||||
}, res.status_code
|
||||
|
||||
if name in defined:
|
||||
return {
|
||||
"method": "systemd",
|
||||
"name": name,
|
||||
"unit": f"{name}.service",
|
||||
"definition": defined[name],
|
||||
"cmd": f"systemctl --user {action} {name}",
|
||||
"exit": _sys["code"],
|
||||
"output": _sys["out"],
|
||||
}
|
||||
|
||||
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
|
||||
|
||||
return _systemd_then_podman(_systemd_call, _podman_call)
|
||||
|
||||
|
||||
@app.get("/debug/defined-containers")
|
||||
def debug_defined_containers():
|
||||
return find_defined_containers()
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
def get_dashboard():
|
||||
# Legacy dashboard view (keep shape)
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except:
|
||||
api_containers = []
|
||||
items = []
|
||||
for c in api_containers:
|
||||
items.append(_legacy_dashboard_item_from_container(c))
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/test-hybrid")
|
||||
def test_hybrid():
|
||||
# 1. Check filesystem
|
||||
try:
|
||||
bestanden = []
|
||||
for root, _, files in os.walk(WORKLOADS_DIR):
|
||||
for f in files:
|
||||
bestanden.append(os.path.join(root, f))
|
||||
except Exception as e:
|
||||
bestanden = f"FS Fout: {str(e)}"
|
||||
|
||||
# 2. Check Podman API
|
||||
try:
|
||||
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
|
||||
except Exception as e:
|
||||
api_containers = f"API Fout: {str(e)}"
|
||||
|
||||
return {
|
||||
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
|
||||
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
|
||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/containers/logs/{name}")
|
||||
def get_container_logs(name: str):
|
||||
# We vragen de laatste 100 regels op (tail=100)
|
||||
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
|
||||
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
|
||||
return {"logs": txt}
|
||||
|
||||
|
||||
@app.get("/containers/inspect/{name}")
|
||||
def inspect_container(name: str):
|
||||
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
|
||||
|
||||
|
||||
# --- SYSTEMD allowlist ---
|
||||
def read_allowlist():
|
||||
units = []
|
||||
if os.path.exists(ALLOWLIST_FILE):
|
||||
with open(ALLOWLIST_FILE, "r") as f:
|
||||
for line in f:
|
||||
u = line.strip()
|
||||
if u and u.endswith(".service"):
|
||||
units.append(u)
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def list_unit_files():
|
||||
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
|
||||
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
|
||||
if code != 0:
|
||||
return []
|
||||
units = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0].endswith(".service"):
|
||||
units.append(parts[0])
|
||||
return sorted(set(units))
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
# active state
|
||||
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
|
||||
active = active.splitlines()[0].strip() if active else "unknown"
|
||||
# enabled state (kan falen in container-context)
|
||||
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
|
||||
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
|
||||
return active, enabled
|
||||
|
||||
|
||||
@app.get("/systemd/allowlist")
|
||||
def systemd_allowlist():
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if not units:
|
||||
units = list_unit_files()
|
||||
return {"allow_mode": allow_mode, "units": units}
|
||||
|
||||
|
||||
@app.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
"output": out,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/{action}/{unit}")
|
||||
def api_action(action: str, unit: str):
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
units = read_allowlist()
|
||||
allow_mode = len(units) > 0
|
||||
if allow_mode and unit not in units:
|
||||
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
|
||||
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
# --- HTML ROOT (legacy) ---
|
||||
HTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
|
||||
<body>
|
||||
<h1>User Systemctl WebUI</h1>
|
||||
|
||||
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
|
||||
<button type="submit">systemctl --user daemon-reload</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/action">
|
||||
<label>Unit:
|
||||
<input name="unit" value="example.service">
|
||||
</label>
|
||||
<select name="action">
|
||||
<option value="status">status</option>
|
||||
<option value="start">start</option>
|
||||
<option value="stop">stop</option>
|
||||
<option value="restart">restart</option>
|
||||
</select>
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
# legacy html
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(HTML)
|
||||
|
||||
|
||||
@app.post("/action")
|
||||
def html_action(unit: str = Query(None), action: str = Query(None)):
|
||||
# legacy handler; minimal behavior
|
||||
if not unit or not action:
|
||||
return "Invalid action", 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _systemctl(cmd)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
@app.post("/api/<action>/<unit>")
|
||||
def legacy_api_action(action: str, unit: str):
|
||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
||||
if action not in ("status", "start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
code, out = _run_systemctl_action(action, unit)
|
||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
return result.returncode, output.strip()
|
||||
except Exception as e:
|
||||
return 1, str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://127.0.0.1:8080/api"
|
||||
ALLOWLIST_FILE="$HOME/systemctl-webui/allowed_units.txt"
|
||||
LOG_FILE="./systemd_api_test_$(date +%F_%H%M%S).log"
|
||||
|
||||
# Helper: log zowel naar scherm als bestand
|
||||
log() {
|
||||
echo "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Helper: POST en log response
|
||||
post() {
|
||||
local path="$1"
|
||||
log ""
|
||||
log "### POST $BASE_URL$path"
|
||||
# -sS: silent maar errors tonen, -i: headers ook, voor debugging
|
||||
curl -sS -i -X POST "$BASE_URL$path" | tee -a "$LOG_FILE"
|
||||
log ""
|
||||
}
|
||||
|
||||
# URL-encode alleen het @ teken (meest voorkomende issue)
|
||||
# (Als je later meer nodig hebt, kunnen we dat uitbreiden)
|
||||
encode_unit() {
|
||||
echo "$1" | sed 's/@/%40/g'
|
||||
}
|
||||
|
||||
log "== Systemd API test gestart =="
|
||||
log "Base URL: $BASE_URL"
|
||||
log "Allowlist: $ALLOWLIST_FILE"
|
||||
log "Log file: $LOG_FILE"
|
||||
|
||||
if [[ ! -f "$ALLOWLIST_FILE" ]]; then
|
||||
log "ERROR: allowlist bestand niet gevonden: $ALLOWLIST_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "== 1) daemon-reload =="
|
||||
post "/daemon-reload"
|
||||
|
||||
log ""
|
||||
log "== 2) status van allowlisted units =="
|
||||
while IFS= read -r unit; do
|
||||
unit="$(echo "$unit" | sed 's/#.*//; s/[[:space:]]//g')"
|
||||
[[ -z "$unit" ]] && continue
|
||||
[[ "$unit" != *.service ]] && continue
|
||||
|
||||
enc_unit="$(encode_unit "$unit")"
|
||||
post "/status/$enc_unit"
|
||||
done < "$ALLOWLIST_FILE"
|
||||
|
||||
log ""
|
||||
log "== 3) start sonarr.service (als in allowlist) =="
|
||||
if grep -qE '^[[:space:]]*sonarr\.service[[:space:]]*$' "$ALLOWLIST_FILE"; then
|
||||
post "/start/sonarr.service"
|
||||
log "== 4) status sonarr.service na start =="
|
||||
post "/status/sonarr.service"
|
||||
else
|
||||
log "SKIP: sonarr.service staat niet in allowlist"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "== Klaar. Output staat in: $LOG_FILE =="
|
||||
@@ -1,168 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import importlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int = 200, json_data=None, text_data: str | None = None):
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data
|
||||
self._text_data = text_data
|
||||
|
||||
def json(self):
|
||||
return self._json_data
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if self._text_data is not None:
|
||||
return self._text_data
|
||||
# Best-effort string
|
||||
return ""
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
Test-only Podman session stub.
|
||||
Routes by URL substring to return deterministic JSON.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get(self, url: str):
|
||||
self.calls.append(("GET", url))
|
||||
|
||||
# Pods list (DYNAMIC)
|
||||
if "/libpod/pods/json" in url:
|
||||
return FakeResponse(200, json_data=[])
|
||||
|
||||
# Containers list (DYNAMIC)
|
||||
if "/libpod/containers/json" in url:
|
||||
return FakeResponse(200, json_data=[])
|
||||
|
||||
# Container inspect (DYNAMIC)
|
||||
if "/libpod/containers/" in url and url.endswith("/json"):
|
||||
# Return some dict to prove JSON
|
||||
return FakeResponse(200, json_data={"Id": "dummy"})
|
||||
|
||||
# Container logs (FIXED endpoint expects {"logs": "..."} from wrapper endpoint, not Podman directly,
|
||||
# but app.py fetches Podman logs and then wraps into {"logs": text}.
|
||||
if "/libpod/containers/" in url and "/logs?" in url:
|
||||
return FakeResponse(200, json_data=None, text_data="line1\nline2\n")
|
||||
|
||||
# Fallback: return JSON dict
|
||||
return FakeResponse(200, json_data={})
|
||||
|
||||
def post(self, url: str, **kwargs):
|
||||
self.calls.append(("POST", url, kwargs))
|
||||
|
||||
# kube/play passthrough (workloads deploy)
|
||||
if "/libpod/kube/play" in url:
|
||||
# app.py gebruikt SESSION.post(url, data=yaml_content).json()
|
||||
return FakeResponse(200, json_data={"kube": "played"})
|
||||
|
||||
# Container start/stop/restart: treat as success
|
||||
if "/libpod/containers/" in url:
|
||||
return FakeResponse(204, json_data={})
|
||||
|
||||
# Pod start/stop etc: treat as success
|
||||
if "/libpod/pods/" in url:
|
||||
return FakeResponse(200, json_data={"ok": True})
|
||||
|
||||
return FakeResponse(200, json_data={})
|
||||
|
||||
|
||||
def delete(self, url: str):
|
||||
self.calls.append(("DELETE", url))
|
||||
return FakeResponse(200, json_data={"deleted": True})
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_module():
|
||||
"""
|
||||
Import app.py as module `app` from /app, while ensuring requests_unixsocket import is safe.
|
||||
No runtime changes to app.py; only test-time monkeypatching later.
|
||||
"""
|
||||
# Ensure /app is importable
|
||||
if "/app" not in sys.path:
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
# If requests_unixsocket isn't installed or to avoid real socket usage, provide a minimal stub.
|
||||
if "requests_unixsocket" not in sys.modules:
|
||||
mod = types.ModuleType("requests_unixsocket")
|
||||
mod.Session = lambda: FakeSession()
|
||||
sys.modules["requests_unixsocket"] = mod
|
||||
|
||||
app = importlib.import_module("app")
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app_module, monkeypatch, tmp_path):
|
||||
"""
|
||||
TestClient with all external side-effects stubbed:
|
||||
- Podman socket calls via app.SESSION
|
||||
- systemctl calls via app.run
|
||||
- filesystem roots via WORKLOADS_DIR + ALLOWLIST_FILE
|
||||
"""
|
||||
# Prepare temp workload tree under tmp_path
|
||||
workloads_dir = tmp_path / "workloads"
|
||||
systemd_dir = workloads_dir / "systemd"
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# A defined container (*.container) for /containers-dashboard defined entry
|
||||
(systemd_dir / "sonarr.container").write_text("[Container]\nImage=dummy\n", encoding="utf-8")
|
||||
|
||||
# Files endpoints operate under WORKLOADS_DIR; UI focuses on systemd subtree
|
||||
# Create a file for /files/read/save/delete
|
||||
(systemd_dir / "test.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
# Create a non-empty dir for /files/rmdir 409 scenario
|
||||
nonempty_dir = systemd_dir / "nonempty"
|
||||
nonempty_dir.mkdir(parents=True, exist_ok=True)
|
||||
(nonempty_dir / "keep.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# Create a yaml to create a defined pod entry for /pods-dashboard
|
||||
(workloads_dir / "mediaserver.yaml").write_text("kind: Pod\nmetadata:\n name: mediaserver\n", encoding="utf-8")
|
||||
|
||||
# Allowlist file for /systemd/allowlist and allow-mode enforcement
|
||||
allowlist_file = tmp_path / "allowed_units.txt"
|
||||
allowlist_file.write_text("sonarr.service\nmediaserver.service\n", encoding="utf-8")
|
||||
|
||||
# Patch module globals to point at tmp filesystem (test-only)
|
||||
monkeypatch.setattr(app_module, "WORKLOADS_DIR", str(workloads_dir))
|
||||
monkeypatch.setattr(app_module, "ALLOWLIST_FILE", str(allowlist_file))
|
||||
|
||||
# Stub Podman session object
|
||||
monkeypatch.setattr(app_module, "SESSION", FakeSession())
|
||||
|
||||
# Stub systemctl runner
|
||||
def fake_run(cmd):
|
||||
# cmd is a list, e.g. ["systemctl","--user","is-active","mediaserver.service"]
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if "is-active mediaserver.service" in cmd_str:
|
||||
return 0, "active"
|
||||
if "is-active sonarr.service" in cmd_str:
|
||||
return 0, "inactive"
|
||||
|
||||
# For POST /{action}/{unit}, return something stable
|
||||
if cmd[:3] == ["systemctl", "--user", "status"]:
|
||||
return 0, "Active: active (running)"
|
||||
if cmd[:3] == ["systemctl", "--user", "daemon-reload"]:
|
||||
return 0, "ok"
|
||||
if cmd[:3] == ["systemctl", "--user", "start"]:
|
||||
return 0, "started"
|
||||
if cmd[:3] == ["systemctl", "--user", "stop"]:
|
||||
return 0, "stopped"
|
||||
if cmd[:3] == ["systemctl", "--user", "restart"]:
|
||||
return 0, "restarted"
|
||||
|
||||
# fallback
|
||||
return 0, "ok"
|
||||
|
||||
monkeypatch.setattr(app_module, "run", fake_run)
|
||||
|
||||
return TestClient(app_module.app)
|
||||
@@ -1,208 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
# ---- LOCKED BASELINE: paths + methods ----
|
||||
BASELINE_ROUTES = {
|
||||
("GET", "/workloads"),
|
||||
("GET", "/workloads/read/{filename:path}"),
|
||||
("POST", "/workloads/save-file"),
|
||||
("POST", "/workloads/deploy/{filename:path}"),
|
||||
("DELETE", "/files/rmdir"),
|
||||
("GET", "/pods"),
|
||||
("POST", "/actions/{action}/{name}"),
|
||||
("GET", "/pods-dashboard"),
|
||||
("POST", "/pods/actions/{action}/{podname}"),
|
||||
("GET", "/containers-dashboard"),
|
||||
("GET", "/containers"),
|
||||
("POST", "/containers/{action}/{name}"),
|
||||
("GET", "/debug/defined-containers"),
|
||||
("GET", "/dashboard"),
|
||||
("GET", "/test-hybrid"),
|
||||
("GET", "/files/tree"),
|
||||
("GET", "/files/read"),
|
||||
("POST", "/files/save"),
|
||||
("DELETE", "/files/delete"),
|
||||
("POST", "/files/mkdir"),
|
||||
("GET", "/containers/logs/{name}"),
|
||||
("GET", "/containers/inspect/{name}"),
|
||||
("GET", "/systemd/allowlist"),
|
||||
("GET", "/"),
|
||||
("POST", "/daemon-reload"),
|
||||
("POST", "/{action}/{unit}"),
|
||||
("POST", "/action"),
|
||||
("POST", "/api/<action>/<unit>"),
|
||||
}
|
||||
|
||||
|
||||
def _route_tuples_from_app(app):
|
||||
found = set()
|
||||
for r in app.routes:
|
||||
methods = getattr(r, "methods", None)
|
||||
path = getattr(r, "path", None)
|
||||
if not methods or not path:
|
||||
continue
|
||||
for m in methods:
|
||||
# ignore implicit HEAD/OPTIONS etc; baseline is explicit
|
||||
if m in {"HEAD", "OPTIONS"}:
|
||||
continue
|
||||
found.add((m, path))
|
||||
return found
|
||||
|
||||
|
||||
def _assert_keys_exact(obj: dict, expected_keys: list[str]):
|
||||
assert isinstance(obj, dict), f"Expected dict, got {type(obj)}"
|
||||
assert sorted(list(obj.keys())) == sorted(expected_keys), f"Keys mismatch: {sorted(obj.keys())} != {sorted(expected_keys)}"
|
||||
|
||||
|
||||
# ---- 1) Route existence: all 28 paths/methods ----
|
||||
def test_baseline_routes_exist(app_module):
|
||||
found = _route_tuples_from_app(app_module.app)
|
||||
missing = sorted(BASELINE_ROUTES - found)
|
||||
assert not missing, f"Missing baseline routes: {missing}"
|
||||
|
||||
|
||||
# ---- 2) FIXED/VARIANT keyset verification ----
|
||||
def test_files_tree_fixed_shape(client):
|
||||
r = client.get("/files/tree")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
if data:
|
||||
_assert_keys_exact(data[0], ["dirs", "files", "path"])
|
||||
|
||||
|
||||
def test_files_read_fixed_shape(client):
|
||||
r = client.get("/files/read", params={"path": "systemd/test.txt"})
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["content"])
|
||||
|
||||
|
||||
def test_files_save_fixed_shape(client):
|
||||
r = client.post("/files/save", params={"path": "systemd/test.txt"}, json={"content": "updated\n"})
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["path", "status"])
|
||||
|
||||
|
||||
def test_files_delete_fixed_shape(client):
|
||||
# create a file first (via save)
|
||||
client.post("/files/save", params={"path": "systemd/delete-me.txt"}, json={"content": "x"})
|
||||
r = client.delete("/files/delete", params={"path": "systemd/delete-me.txt"})
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["status", "type"])
|
||||
|
||||
|
||||
def test_files_mkdir_fixed_shape(client):
|
||||
r = client.post("/files/mkdir", params={"path": "systemd/newdir"})
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["path", "status"])
|
||||
|
||||
|
||||
def test_files_rmdir_variant_shapes(client):
|
||||
# 409: directory not empty -> FastAPI HTTPException detail object
|
||||
r = client.delete("/files/rmdir", params={"path": "systemd/nonempty"})
|
||||
assert r.status_code == 409
|
||||
body = r.json()
|
||||
_assert_keys_exact(body, ["detail"])
|
||||
assert isinstance(body["detail"], dict)
|
||||
_assert_keys_exact(body["detail"], ["dirs", "error", "files"])
|
||||
|
||||
# 200: empty directory -> success keys
|
||||
client.post("/files/mkdir", params={"path": "systemd/emptydir"})
|
||||
r2 = client.delete("/files/rmdir", params={"path": "systemd/emptydir"})
|
||||
assert r2.status_code == 200
|
||||
_assert_keys_exact(r2.json(), ["deleted", "path"])
|
||||
|
||||
|
||||
def test_systemd_allowlist_fixed_shape(client):
|
||||
r = client.get("/systemd/allowlist")
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["allow_mode", "units"])
|
||||
|
||||
|
||||
def test_daemon_reload_fixed_shape(client):
|
||||
r = client.post("/daemon-reload")
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["cmd", "exit", "output"])
|
||||
|
||||
|
||||
def test_systemd_action_fixed_shape_and_allow_enforcement(client):
|
||||
# allowed unit
|
||||
r = client.post("/status/sonarr.service")
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["cmd", "exit", "output"])
|
||||
|
||||
# not allowed unit -> 403 with {"detail": ...}
|
||||
r2 = client.post("/status/notallowed.service")
|
||||
assert r2.status_code == 403
|
||||
_assert_keys_exact(r2.json(), ["detail"])
|
||||
|
||||
|
||||
def test_pods_dashboard_fixed_shape(client):
|
||||
r = client.get("/pods-dashboard")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
# Expect at least the defined pod from mediaserver.yaml -> "podmediaserver"
|
||||
# Keys per item must be fixed
|
||||
if data:
|
||||
_assert_keys_exact(data[0], ["Containers", "Name", "Source", "Status", "Unit"])
|
||||
|
||||
|
||||
def test_containers_dashboard_variant_shape(client):
|
||||
r = client.get("/containers-dashboard")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
# Because we created systemd/sonarr.container, we expect a defined entry
|
||||
# in fixed defined-shape.
|
||||
defined_shape = ["Image", "Names", "PodName", "Ports", "State", "Status",
|
||||
"_dashboard_def_path", "_dashboard_source", "_dashboard_unit"]
|
||||
|
||||
found_defined = False
|
||||
for item in data:
|
||||
if isinstance(item, dict) and item.get("_dashboard_source") == "systemd":
|
||||
_assert_keys_exact(item, defined_shape)
|
||||
found_defined = True
|
||||
break
|
||||
assert found_defined, "Expected at least one defined container entry with fixed defined-shape"
|
||||
|
||||
|
||||
def test_containers_logs_fixed_shape(client):
|
||||
r = client.get("/containers/logs/testcontainer")
|
||||
assert r.status_code == 200
|
||||
_assert_keys_exact(r.json(), ["logs"])
|
||||
|
||||
|
||||
# NOTE: tuple-return endpoints (invalid action) behave as HTTP 200 with JSON list [dict, status]
|
||||
def test_tuple_return_invalid_action_shapes(client):
|
||||
r = client.post("/containers/foo/bar")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert isinstance(body, list) and len(body) == 2
|
||||
assert isinstance(body[0], dict) and "error" in body[0]
|
||||
|
||||
r2 = client.post("/pods/actions/foo/podx")
|
||||
assert r2.status_code == 200
|
||||
body2 = r2.json()
|
||||
assert isinstance(body2, list) and len(body2) == 2
|
||||
assert isinstance(body2[0], dict) and "error" in body2[0]
|
||||
|
||||
|
||||
# ---- 3) DYNAMIC endpoints: statuscode + is JSON only ----
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,kwargs",
|
||||
[
|
||||
("GET", "/pods", {}),
|
||||
("GET", "/containers", {}),
|
||||
("GET", "/containers/inspect/testcontainer", {}),
|
||||
("POST", "/workloads/deploy/mediaserver.yaml", {}),
|
||||
("GET", "/debug/defined-containers", {}),
|
||||
],
|
||||
)
|
||||
def test_dynamic_endpoints_only_json_and_200(client, method, path, kwargs):
|
||||
req = getattr(client, method.lower())
|
||||
r = req(path, **kwargs)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, (dict, list)), f"Expected JSON (dict/list), got {type(data)}"
|
||||
@@ -1,168 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import importlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int = 200, json_data=None, text_data: str | None = None):
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data
|
||||
self._text_data = text_data
|
||||
|
||||
def json(self):
|
||||
return self._json_data
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if self._text_data is not None:
|
||||
return self._text_data
|
||||
# Best-effort string
|
||||
return ""
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
Test-only Podman session stub.
|
||||
Routes by URL substring to return deterministic JSON.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get(self, url: str):
|
||||
self.calls.append(("GET", url))
|
||||
|
||||
# Pods list (DYNAMIC)
|
||||
if "/libpod/pods/json" in url:
|
||||
return FakeResponse(200, json_data=[])
|
||||
|
||||
# Containers list (DYNAMIC)
|
||||
if "/libpod/containers/json" in url:
|
||||
return FakeResponse(200, json_data=[])
|
||||
|
||||
# Container inspect (DYNAMIC)
|
||||
if "/libpod/containers/" in url and url.endswith("/json"):
|
||||
# Return some dict to prove JSON
|
||||
return FakeResponse(200, json_data={"Id": "dummy"})
|
||||
|
||||
# Container logs (FIXED endpoint expects {"logs": "..."} from wrapper endpoint, not Podman directly,
|
||||
# but app.py fetches Podman logs and then wraps into {"logs": text}.
|
||||
if "/libpod/containers/" in url and "/logs?" in url:
|
||||
return FakeResponse(200, json_data=None, text_data="line1\nline2\n")
|
||||
|
||||
# Fallback: return JSON dict
|
||||
return FakeResponse(200, json_data={})
|
||||
|
||||
def post(self, url: str, **kwargs):
|
||||
self.calls.append(("POST", url, kwargs))
|
||||
|
||||
# kube/play passthrough (workloads deploy)
|
||||
if "/libpod/kube/play" in url:
|
||||
# app.py gebruikt SESSION.post(url, data=yaml_content).json()
|
||||
return FakeResponse(200, json_data={"kube": "played"})
|
||||
|
||||
# Container start/stop/restart: treat as success
|
||||
if "/libpod/containers/" in url:
|
||||
return FakeResponse(204, json_data={})
|
||||
|
||||
# Pod start/stop etc: treat as success
|
||||
if "/libpod/pods/" in url:
|
||||
return FakeResponse(200, json_data={"ok": True})
|
||||
|
||||
return FakeResponse(200, json_data={})
|
||||
|
||||
|
||||
def delete(self, url: str):
|
||||
self.calls.append(("DELETE", url))
|
||||
return FakeResponse(200, json_data={"deleted": True})
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_module():
|
||||
"""
|
||||
Import app.py as module `app` from /app, while ensuring requests_unixsocket import is safe.
|
||||
No runtime changes to app.py; only test-time monkeypatching later.
|
||||
"""
|
||||
# Ensure /app is importable
|
||||
if "/app" not in sys.path:
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
# If requests_unixsocket isn't installed or to avoid real socket usage, provide a minimal stub.
|
||||
if "requests_unixsocket" not in sys.modules:
|
||||
mod = types.ModuleType("requests_unixsocket")
|
||||
mod.Session = lambda: FakeSession()
|
||||
sys.modules["requests_unixsocket"] = mod
|
||||
|
||||
app = importlib.import_module("app")
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app_module, monkeypatch, tmp_path):
|
||||
"""
|
||||
TestClient with all external side-effects stubbed:
|
||||
- Podman socket calls via app.SESSION
|
||||
- systemctl calls via app.run
|
||||
- filesystem roots via WORKLOADS_DIR + ALLOWLIST_FILE
|
||||
"""
|
||||
# Prepare temp workload tree under tmp_path
|
||||
workloads_dir = tmp_path / "workloads"
|
||||
systemd_dir = workloads_dir / "systemd"
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# A defined container (*.container) for /containers-dashboard defined entry
|
||||
(systemd_dir / "sonarr.container").write_text("[Container]\nImage=dummy\n", encoding="utf-8")
|
||||
|
||||
# Files endpoints operate under WORKLOADS_DIR; UI focuses on systemd subtree
|
||||
# Create a file for /files/read/save/delete
|
||||
(systemd_dir / "test.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
# Create a non-empty dir for /files/rmdir 409 scenario
|
||||
nonempty_dir = systemd_dir / "nonempty"
|
||||
nonempty_dir.mkdir(parents=True, exist_ok=True)
|
||||
(nonempty_dir / "keep.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# Create a yaml to create a defined pod entry for /pods-dashboard
|
||||
(workloads_dir / "mediaserver.yaml").write_text("kind: Pod\nmetadata:\n name: mediaserver\n", encoding="utf-8")
|
||||
|
||||
# Allowlist file for /systemd/allowlist and allow-mode enforcement
|
||||
allowlist_file = tmp_path / "allowed_units.txt"
|
||||
allowlist_file.write_text("sonarr.service\nmediaserver.service\n", encoding="utf-8")
|
||||
|
||||
# Patch module globals to point at tmp filesystem (test-only)
|
||||
monkeypatch.setattr(app_module, "WORKLOADS_DIR", str(workloads_dir))
|
||||
monkeypatch.setattr(app_module, "ALLOWLIST_FILE", str(allowlist_file))
|
||||
|
||||
# Stub Podman session object
|
||||
monkeypatch.setattr(app_module, "SESSION", FakeSession())
|
||||
|
||||
# Stub systemctl runner
|
||||
def fake_run(cmd):
|
||||
# cmd is a list, e.g. ["systemctl","--user","is-active","mediaserver.service"]
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if "is-active mediaserver.service" in cmd_str:
|
||||
return 0, "active"
|
||||
if "is-active sonarr.service" in cmd_str:
|
||||
return 0, "inactive"
|
||||
|
||||
# For POST /{action}/{unit}, return something stable
|
||||
if cmd[:3] == ["systemctl", "--user", "status"]:
|
||||
return 0, "Active: active (running)"
|
||||
if cmd[:3] == ["systemctl", "--user", "daemon-reload"]:
|
||||
return 0, "ok"
|
||||
if cmd[:3] == ["systemctl", "--user", "start"]:
|
||||
return 0, "started"
|
||||
if cmd[:3] == ["systemctl", "--user", "stop"]:
|
||||
return 0, "stopped"
|
||||
if cmd[:3] == ["systemctl", "--user", "restart"]:
|
||||
return 0, "restarted"
|
||||
|
||||
# fallback
|
||||
return 0, "ok"
|
||||
|
||||
monkeypatch.setattr(app_module, "run", fake_run)
|
||||
|
||||
return TestClient(app_module.app)
|
||||
@@ -1,165 +0,0 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def load_app_module():
|
||||
try:
|
||||
return importlib.import_module("app")
|
||||
except ModuleNotFoundError:
|
||||
app_path = Path("/app/app.py")
|
||||
spec = importlib.util.spec_from_file_location("app", app_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /containers/{action}/{name}
|
||||
# ============================================================
|
||||
|
||||
def test_container_action_invalid_action_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/containers/invalid/dummy")
|
||||
|
||||
# Freeze whatever baseline currently is
|
||||
body = r.json()
|
||||
|
||||
# Must be JSON and deterministic structure
|
||||
assert isinstance(body, (dict, list))
|
||||
|
||||
# If dict variant
|
||||
if isinstance(body, dict):
|
||||
assert set(body.keys()) == {"error"}
|
||||
assert body["error"] == "Invalid action"
|
||||
|
||||
|
||||
def test_container_action_failure_shape_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/containers/start/nonexistent_container_12345")
|
||||
|
||||
body = r.json()
|
||||
|
||||
# Freeze exact variant structure
|
||||
if isinstance(body, dict) and body.get("method") == "podman":
|
||||
assert "name" in body
|
||||
assert "cmd" in body
|
||||
assert "status_code" in body
|
||||
else:
|
||||
# Any other baseline variant must still be JSON
|
||||
assert isinstance(body, (dict, list))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /actions/{action}/{name}
|
||||
# ============================================================
|
||||
|
||||
def test_legacy_actions_invalid_action_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/actions/invalid/dummy")
|
||||
body = r.json()
|
||||
|
||||
assert isinstance(body, dict)
|
||||
assert set(body.keys()) == {"status"}
|
||||
assert body["status"] == "unknown"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /pods/actions/{action}/{podname}
|
||||
# ============================================================
|
||||
|
||||
def test_pods_action_invalid_action_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/pods/actions/invalid/dummy")
|
||||
body = r.json()
|
||||
|
||||
# Baseline may return tuple-style 400 or JSON
|
||||
assert isinstance(body, (dict, list))
|
||||
|
||||
if isinstance(body, dict) and "error" in body:
|
||||
assert body["error"] == "Invalid action"
|
||||
|
||||
|
||||
def test_pods_action_variant_shape_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/pods/actions/start/nonexistent_pod_12345")
|
||||
body = r.json()
|
||||
|
||||
# Freeze possible baseline variants
|
||||
if isinstance(body, dict):
|
||||
if body.get("method") == "systemd_then_podman":
|
||||
assert set(body.keys()) == {"method", "note", "systemd", "podman"}
|
||||
assert body["note"] == "systemd failed; falling back to podman"
|
||||
elif body.get("method") == "podman":
|
||||
assert set(body.keys()) == {"method", "result"}
|
||||
else:
|
||||
assert isinstance(body, list)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /{action}/{unit}
|
||||
# ============================================================
|
||||
|
||||
def test_systemctl_wrapper_invalid_action_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/invalid/dummy.service")
|
||||
body = r.json()
|
||||
|
||||
assert set(body.keys()) == {"detail"}
|
||||
assert body["detail"] == "Invalid action"
|
||||
|
||||
|
||||
def test_systemctl_wrapper_status_shape_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/status/nonexistent.service")
|
||||
body = r.json()
|
||||
|
||||
# allowlist may cause 403
|
||||
if r.status_code == 403:
|
||||
assert set(body.keys()) == {"detail"}
|
||||
else:
|
||||
assert set(body.keys()) == {"cmd", "exit", "output"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /api/<action>/<unit>
|
||||
# ============================================================
|
||||
|
||||
def test_legacy_api_invalid_action_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/api/invalid/dummy.service")
|
||||
body = r.json()
|
||||
|
||||
# Baseline: HTTPException 400 with {"detail": "..."}
|
||||
assert r.status_code == 400
|
||||
assert set(body.keys()) == {"detail"}
|
||||
|
||||
|
||||
def test_legacy_api_status_shape_contract():
|
||||
app_mod = load_app_module()
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/api/status/nonexistent.service")
|
||||
body = r.json()
|
||||
|
||||
if r.status_code == 403:
|
||||
assert set(body.keys()) == {"detail"}
|
||||
else:
|
||||
assert set(body.keys()) == {"cmd", "exit", "output"}
|
||||
@@ -1,69 +0,0 @@
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
|
||||
def load_app_module():
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
app_path = os.path.join(repo_root, "app.py")
|
||||
spec = importlib.util.spec_from_file_location("app", app_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _iter_routes(app):
|
||||
for r in getattr(app, "routes", []):
|
||||
path = getattr(r, "path", None)
|
||||
methods = getattr(r, "methods", None)
|
||||
if not path or not methods:
|
||||
continue
|
||||
for m in methods:
|
||||
if m in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
yield (m, path)
|
||||
|
||||
|
||||
def test_contract_routes_exist_and_match_locked_baseline():
|
||||
app_mod = load_app_module()
|
||||
routes = set(_iter_routes(app_mod.app))
|
||||
|
||||
# Endpoint count exact (locked baseline)
|
||||
assert len(routes) == 30
|
||||
|
||||
expected = {
|
||||
# FastAPI built-ins (default)
|
||||
("GET", "/openapi.json"),
|
||||
("GET", "/docs"),
|
||||
("GET", "/docs/oauth2-redirect"),
|
||||
("GET", "/redoc"),
|
||||
|
||||
# App routes
|
||||
("GET", "/workloads"),
|
||||
("GET", "/workloads/read/{filename:path}"),
|
||||
("POST", "/workloads/save-file"),
|
||||
("POST", "/workloads/deploy/{filename:path}"),
|
||||
("GET", "/files/tree"),
|
||||
("GET", "/files/read"),
|
||||
("POST", "/files/save"),
|
||||
("DELETE", "/files/delete"),
|
||||
("POST", "/files/mkdir"),
|
||||
("DELETE", "/files/rmdir"),
|
||||
("GET", "/pods"),
|
||||
("POST", "/actions/{action}/{name}"),
|
||||
("GET", "/pods-dashboard"),
|
||||
("POST", "/pods/actions/{action}/{podname}"),
|
||||
("GET", "/containers-dashboard"),
|
||||
("GET", "/containers"),
|
||||
("POST", "/containers/{action}/{name}"),
|
||||
("GET", "/debug/defined-containers"),
|
||||
("GET", "/dashboard"),
|
||||
("GET", "/test-hybrid"),
|
||||
("GET", "/containers/logs/{name}"),
|
||||
("GET", "/containers/inspect/{name}"),
|
||||
("GET", "/systemd/allowlist"),
|
||||
("POST", "/daemon-reload"),
|
||||
("POST", "/{action}/{unit}"),
|
||||
("POST", "/api/<action>/<unit>"),
|
||||
}
|
||||
|
||||
assert routes == expected
|
||||
@@ -1,124 +0,0 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def load_app_module():
|
||||
try:
|
||||
return importlib.import_module("app")
|
||||
except ModuleNotFoundError:
|
||||
app_path = Path("/app/app.py")
|
||||
spec = importlib.util.spec_from_file_location("app", app_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_files_read_not_found_detail_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
workloads.mkdir()
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.get("/files/read", params={"path": "systemd/nope.txt"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == "Not found"
|
||||
|
||||
|
||||
def test_files_read_is_directory_detail_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
(workloads / "systemd").mkdir(parents=True)
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.get("/files/read", params={"path": "systemd"})
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Is a directory"
|
||||
|
||||
|
||||
def test_files_delete_directory_error_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
(workloads / "systemd").mkdir(parents=True)
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.delete("/files/delete", params={"path": "systemd"})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "Kan niet verwijderen: is directory"
|
||||
|
||||
|
||||
def test_files_mkdir_prefix_systemd_and_keyset(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
workloads.mkdir()
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.post("/files/mkdir", params={"path": "abc"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"status", "path"}
|
||||
assert body["path"] == "systemd/abc"
|
||||
|
||||
|
||||
def test_files_rmdir_refuse_root_detail_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
workloads.mkdir()
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.delete("/files/rmdir", params={"path": "systemd"})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "Refusing to delete systemd root"
|
||||
|
||||
|
||||
def test_files_rmdir_only_systemd_subtree_detail_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
workloads.mkdir()
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.delete("/files/rmdir", params={"path": "not_systemd/x"})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "Only systemd subtree is allowed"
|
||||
|
||||
|
||||
def test_files_rmdir_nonempty_409_detail_shape_exact(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
d = workloads / "systemd" / "dir1"
|
||||
d.mkdir(parents=True)
|
||||
(d / "a.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.delete("/files/rmdir", params={"path": "systemd/dir1"})
|
||||
assert r.status_code == 409
|
||||
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"detail"}
|
||||
detail = body["detail"]
|
||||
assert set(detail.keys()) == {"error", "dirs", "files"}
|
||||
assert detail["error"] == "directory not empty"
|
||||
assert "a.txt" in detail["files"]
|
||||
|
||||
|
||||
def test_files_safe_join_forbidden_path_detail_string(tmp_path, monkeypatch):
|
||||
app_mod = load_app_module()
|
||||
workloads = tmp_path / "workloads"
|
||||
workloads.mkdir()
|
||||
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
|
||||
client = TestClient(app_mod.app)
|
||||
|
||||
r = client.get("/files/read", params={"path": "../escape.txt"})
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Forbidden path"
|
||||
Reference in New Issue
Block a user