server-up/server-up/app.py

1355 lines
52 KiB
Python

"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, shutil, subprocess, sys, time
from pathlib import Path
# Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core'
APP = Path(__file__).resolve().parent
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
import yaml
from flask import Flask, Response, jsonify, render_template, request
import core as cfg
from core import audit, jobs, i18n
from core import docker, git, boilerplates
from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates")
VERSION = os.environ.get("SU_VERSION", "0.3.0")
MODULES: list[Module] = []
USER_MOD = APP / "modules"
BUNDLED_MOD = APP.parent / "modules"
CONTAINER_NAME = os.environ.get("HOSTNAME", os.environ.get("SU_CONTAINER", "server-up"))
# ── Module loading ────────────────────────────────────────────────────────────
def _mod_js(m: Module) -> Path | None:
sd = getattr(m, "_src", None)
if sd:
f = Path(sd) / "static" / "module.js"
if f.exists():
return f
return None
STARTUP_LOG = []
def load_modules():
# Stap 1: Ruim eerder geïnstalleerde CORE modules op uit user_modules
if USER_MOD.exists():
for d in list(USER_MOD.iterdir()):
if d.is_dir() and d.name in CORE:
msg = f"{d.name} (core, verwijderd uit user_modules)"
print(f" {msg}")
STARTUP_LOG.append(msg)
shutil.rmtree(d, ignore_errors=True)
elif d.is_dir():
mf = d / "module.json"
if mf.exists():
try:
meta = json.loads(mf.read_text())
if meta.get("id", d.name) in CORE:
msg = f"{d.name} (core id={meta['id']}, verwijderd)"
print(f" {msg}")
STARTUP_LOG.append(msg)
shutil.rmtree(d, ignore_errors=True)
except Exception:
pass
# Stap 2: Laad optionele modules
active = cfg.load().get("ACTIVE_MODULES")
for mid, cls, d in discover([USER_MOD, BUNDLED_MOD]):
if active is not None and mid not in active:
msg = f"{mid} (uit)"
print(f" {msg}")
STARTUP_LOG.append(msg)
continue
try:
inst = cls()
inst._src = d
if inst.ID in CORE:
msg = f"{mid} (core, overgeslagen)"
print(f" {msg}")
STARTUP_LOG.append(msg)
continue
MODULES.append(inst)
bp = inst.blueprint()
if bp:
try:
app.register_blueprint(bp)
msg = f"{mid} (met routes)"
except Exception as e:
msg = f"{mid} (routes fout: {e})"
else:
msg = f"{mid} (geen routes)"
print(f" {msg}")
STARTUP_LOG.append(msg)
inst.on_load(app)
except Exception as e:
msg = f"{mid}: {e}"
print(f" {msg}")
STARTUP_LOG.append(msg)
def _notify_restart(mid: str) -> tuple[bool, str]:
"""Module is geïnstalleerd; vereist herstart om actief te worden."""
return True, "geïnstalleerd — herstart de container om te activeren"
# ══════════════════════════════════════════════════════════════════════════════
# API
# ══════════════════════════════════════════════════════════════════════════════
@app.route("/")
def index():
resp = render_template("index.html")
return resp
@app.after_request
def no_cache(response):
if request.path == "/" or request.path.endswith(".html"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
# ── Docker ────────────────────────────────────────────────────────────────────
@app.route("/api/docker/info")
def api_info():
d = docker.info()
d["su_version"] = VERSION
return jsonify(d)
@app.route("/api/version")
def api_version():
return jsonify(version=VERSION)
@app.route("/api/docker/images")
def api_images():
return jsonify(images=docker.images())
@app.route("/api/docker/images/remove", methods=["POST"])
def api_images_rm():
d = request.json or {}
ok, msg = docker.rmi(d.get("id", ""), d.get("force", False))
if ok:
audit.log("docker", "rmi", "ok", detail=d.get("id"))
return jsonify(ok=ok, msg=msg)
@app.route("/api/docker/images/prune", methods=["POST"])
def api_images_prune():
ok, msg = docker.prune_images()
if ok:
audit.log("docker", "prune", "ok")
return jsonify(ok=ok, msg=msg)
@app.route("/api/docker/restart", methods=["GET", "POST"])
def api_restart():
"""Restart the Server Up container itself."""
audit.log("core", "restart", "ok", ip=request.remote_addr)
ok, msg = docker.restart_container(CONTAINER_NAME)
return jsonify(ok=ok, msg=msg)
# ── Projects / Stacks ────────────────────────────────────────────────────────
@app.route("/api/stacks")
def api_stacks():
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if not lib.exists():
return jsonify([])
# Bescherm tegen verkeerde LIBRARY_DIR
if lib == Path("/app").resolve():
return jsonify([])
out = []
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
if not docker.has_compose(d):
continue
ct = docker.compose_ps(d, name=d.name)
out.append({
"name": d.name, "path": str(d), "has_compose": True,
"running": any(c["running"] for c in ct),
"containers": ct,
})
return jsonify(out)
@app.route("/api/stacks/<name>/<action>", methods=["POST"])
def api_stack_action(name, action):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
if not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
if action not in ("start", "stop", "restart", "update", "remove", "backup"):
return jsonify(ok=False, msg="onbekend"), 400
body = request.json or {}
jid, q = jobs.create(f"{action}:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name
lf = lambda m: jobs.log(qq, "dim", m)
try:
jobs.log(qq, "section", f"{action}: {name}")
rc = 0
if action == "start":
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "stop":
rc = docker.compose_down(pdir, log_fn=lf, name=name)
elif action == "restart":
docker.compose_down(pdir, log_fn=lf, name=name)
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "update":
docker.compose_pull(pdir, log_fn=lf)
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "remove":
mode = body.get("mode", "stack")
if docker.has_compose(pdir):
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
if mode in ("full", "all") and pdir.is_dir():
shutil.rmtree(pdir)
jobs.log(qq, "ok", "Bestanden verwijderd")
if mode == "all":
dd = Path(cfg.load()["DATA_DIR"]).resolve() / name
if dd.is_dir():
shutil.rmtree(dd)
jobs.log(qq, "ok", "Data verwijderd")
elif action == "backup":
bd = Path(cfg.load()["BACKUP_DIR"]).resolve()
bd.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"),
"-C", str(pdir.parent), name],
capture_output=True, timeout=300)
jobs.log(qq, "ok", f"Backup → {bd / f'{safe_name}_{ts}.tar.gz'}")
jobs.log(qq, "ok" if rc == 0 else "error",
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/stacks/<name>/env")
def api_stack_env(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
return jsonify(content=docker.read_env(d))
@app.route("/api/stacks/<name>/env", methods=["PUT"])
def api_stack_env_put(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
docker.write_env(d, (request.json or {}).get("content", ""))
return jsonify(ok=True)
@app.route("/api/stacks/<name>/compose")
def api_stack_compose(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
return jsonify(content=docker.read_compose(d))
@app.route("/api/stacks/<name>/compose", methods=["PUT"])
def api_stack_compose_put(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
docker.write_compose(d, (request.json or {}).get("content", ""))
return jsonify(ok=True)
@app.route("/api/stacks/<name>/logs")
def api_stack_logs(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
return jsonify(logs=docker.compose_logs(d, name=name))
# ── App Store ─────────────────────────────────────────────────────────────────
@app.route("/api/repos")
def api_repos():
repos = cfg.load().get("APP_REPOS", [])
result = [{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos]
return jsonify(result)
@app.route("/api/repos", methods=["POST"])
def api_repos_add():
d = request.json or {}
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("APP_REPOS", []))
existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time()))
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"),
"token": d.get("token", ""), "subdir": d.get("subdir", "")})
cfg.patch({"APP_REPOS": repos})
audit.log("repos", "add", "ok", ref=rid)
return jsonify(ok=True)
@app.route("/api/repos/<rid>", methods=["DELETE"])
def api_repos_del(rid):
repos = [r for r in cfg.load().get("APP_REPOS", []) if r["id"] != rid]
cfg.patch({"APP_REPOS": repos})
c = git.cache_dir(rid)
if c.exists():
shutil.rmtree(c)
audit.log("repos", "delete", "ok", ref=rid)
return jsonify(ok=True)
@app.route("/api/repos/<rid>/sync", methods=["GET", "POST"])
def api_repos_sync(rid):
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return jsonify(ok=False, msg="niet gevonden")
jid, q = jobs.create(f"sync:{rid}")
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/repos/<rid>/stacks")
def api_repos_stacks(rid):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return jsonify(stacks=[])
return jsonify(stacks=git.scan_stacks(rid, lib, subdir=repo.get("subdir", "")))
def _is_stack_dir(p: Path) -> bool:
"""True als de map ofwel een legacy compose-stack ofwel een boilerplate is."""
return docker.has_compose(p) or boilerplates.is_boilerplate(p)
def _find_stack_src(stack: str, rid: str) -> Path | None:
"""Zoek de stack bronmap in de git cache."""
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return None
cache_root = git.cache_dir(rid)
# Poging 1: verwacht pad
expected = cache_root
if repo.get("subdir"):
expected = expected / repo["subdir"]
expected = expected / stack
if expected.exists() and _is_stack_dir(expected):
return expected
# Poging 2: bekende submappen
for fb in ("apps", "stacks", "docker", "compose"):
p = cache_root / fb / stack
if p.exists() and _is_stack_dir(p):
return p
# Poging 3: direct in root
direct = cache_root / stack
if direct.exists() and _is_stack_dir(direct):
return direct
# Poging 4: recursief
if cache_root.exists():
for p in cache_root.rglob("*"):
if p.is_dir() and p.name == stack and ".git" not in str(p) and _is_stack_dir(p):
return p
return None
@app.route("/api/store/preview", methods=["POST"])
def api_store_preview():
"""Preview een stack: lees compose + .env en extract configureerbare velden.
Voor boilerplate-stacks wordt de variabelen-schema uit template.json
teruggegeven zodat de install-modal een rijk formulier kan tonen."""
d = request.json or {}
stack = d.get("stack", "").strip()
rid = d.get("repo_id", "")
if not stack or not rid:
return jsonify(ok=False, msg="stack en repo_id vereist")
src = _find_stack_src(stack, rid)
if not src:
return jsonify(ok=False, msg=f"'{stack}' niet gevonden")
# ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
if boilerplates.is_boilerplate(src):
meta = boilerplates.metadata(src)
flds = boilerplates.fields(src)
# Render een preview met defaults, zodat de gebruiker ziet wat er komt
try:
ctx = boilerplates.build_context(src, {})
files_dir = src / "files"
preview_files = {}
for p in sorted(files_dir.rglob("*")):
if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000:
preview_files[p.relative_to(files_dir).as_posix()] = \
boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:4000]
except Exception as e:
preview_files = {"_error": f"render: {e}"}
return jsonify(
ok=True,
stack=stack,
repo_id=rid,
format="boilerplate",
metadata=meta,
fields=flds,
preview_files=preview_files,
# Backwards-compat voor de oude UI:
env_vars=[], images=[], ports=[],
compose_preview=preview_files.get("compose.yaml", "") or
preview_files.get("docker-compose.yml", ""),
env_preview="",
)
# ── Legacy Server Up formaat (compose.yml + .env) ─────────────────────────
result = {"stack": stack, "repo_id": rid, "format": "compose",
"env_vars": [], "images": [], "ports": []}
# Parse .env
ef = src / ".env"
if ef.exists():
for line in ef.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
is_port = "PORT" in k.upper()
is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION")
result["env_vars"].append({
"key": k, "value": v,
"is_port": is_port, "is_tag": is_tag,
"desc": "Poort" if is_port else "Image tag" if is_tag else "",
})
if is_port:
try:
result["ports"].append({"key": k, "value": int(v)})
except ValueError:
result["ports"].append({"key": k, "value": v})
# Parse compose voor images
cf = docker.find_compose(src)
if cf:
try:
compose = yaml.safe_load(cf.read_text())
for svc_name, svc in (compose.get("services") or {}).items():
img = svc.get("image", "")
if img:
repo_part = img.split(":")[0] if ":" in img else img
tag_part = img.split(":")[-1] if ":" in img else "latest"
result["images"].append({
"service": svc_name, "image": img,
"repo": repo_part, "tag": tag_part,
})
# Also check ports in compose
for p in svc.get("ports", []):
ps = str(p)
if ":" in ps:
host_port = ps.split(":")[0].strip().strip('"').strip("'")
# Check if it's a variable reference
if "$" not in host_port:
try:
result["ports"].append({"key": f"{svc_name}_port", "value": int(host_port), "from_compose": True})
except ValueError:
pass
except Exception:
# parse error — try regex
content = cf.read_text()
for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content):
img = m.group(1)
repo_part = img.split(":")[0] if ":" in img else img
tag_part = img.split(":")[-1] if ":" in img else "latest"
if "${" not in img: # Skip variable references
result["images"].append({"service": "?", "image": img, "repo": repo_part, "tag": tag_part})
# Compose bestand inhoud voor preview
if cf:
result["compose_preview"] = cf.read_text()[:2000]
result["env_preview"] = ef.read_text()[:1000] if ef.exists() else ""
result["ok"] = True
return jsonify(result)
@app.route("/api/store/install", methods=["POST"])
def api_store_install():
d = request.json or {}
stack = d.get("stack", "").strip()
inst = d.get("instance", "").strip() or stack
rid = d.get("repo_id", "")
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
bp_values = d.get("values", {}) # boilerplate variable values
if not stack:
return jsonify(ok=False, msg="stack vereist")
src = _find_stack_src(stack, rid)
if not src:
return jsonify(ok=False, msg=f"'{stack}' niet gevonden")
is_bp = boilerplates.is_boilerplate(src)
if not is_bp and not docker.has_compose(src):
return jsonify(ok=False, msg=f"'{stack}' heeft geen compose bestand")
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if lib == Path("/app").resolve():
return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.")
try:
lib.mkdir(parents=True, exist_ok=True)
except Exception as e:
return jsonify(ok=False, msg=f"Library map kan niet aangemaakt worden: {e}")
dest = (lib / inst).resolve()
if dest.exists():
return jsonify(ok=False, msg=f"'{inst}' bestaat al")
jid, q = jobs.create(f"install:{inst}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Installeren: {inst}")
try:
dest.mkdir(parents=True, exist_ok=True)
except PermissionError:
jobs.log(qq, "error", f"Geen schrijfrechten op '{dest}'")
jobs.finish(jid, "error"); jobs.done(qq); return
if is_bp:
# ── Boilerplate: render variabelen → compose-bestand ──────────
values = dict(bp_values or {})
# Defaults voor service_name als de gebruiker niets opgaf
values.setdefault("service_name", inst)
jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)")
for k, v in sorted(values.items()):
jobs.log(qq, "dim", f" {k}={v}")
try:
written = boilerplates.render_to_dir(src, dest, values)
for rel in written:
jobs.log(qq, "dim", f"{rel}")
jobs.log(qq, "ok", f"Gerenderd ({len(written)} bestanden)")
except boilerplates.BoilerplateError as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error"); jobs.done(qq); return
else:
# ── Legacy: copy verbatim ─────────────────────────────────────
shutil.copytree(str(src), str(dest), dirs_exist_ok=True)
jobs.log(qq, "ok", f"Gekopieerd → {dest}")
# .env aanpassen met overrides
ef = dest / ".env"
if ef.exists() and env_overrides:
lines = ef.read_text().splitlines()
new_lines = []
applied = set()
for line in lines:
if "=" in line and not line.strip().startswith("#"):
k = line.split("=", 1)[0].strip()
if k in env_overrides:
new_lines.append(f"{k}={env_overrides[k]}")
applied.add(k)
jobs.log(qq, "dim", f" {k}={env_overrides[k]}")
continue
new_lines.append(line)
# Voeg nieuwe keys toe die niet in het origineel stonden
for k, v in env_overrides.items():
if k not in applied:
new_lines.append(f"{k}={v}")
jobs.log(qq, "dim", f" {k}={v} (nieuw)")
ef.write_text("\n".join(new_lines) + "\n")
jobs.log(qq, "ok", f"Env geconfigureerd ({len(env_overrides)} waarden)")
elif not ef.exists() and env_overrides:
# Maak .env aan
ef.write_text("\n".join(f"{k}={v}" for k, v in env_overrides.items()) + "\n")
jobs.log(qq, "ok", f".env aangemaakt ({len(env_overrides)} waarden)")
# Image tags aanpassen in compose
if image_tags:
cf = docker.find_compose(dest)
if cf:
content = cf.read_text()
for svc, tag in image_tags.items():
if not tag:
continue
# Vervang image:xxx tag
# Simple approach: replace all image lines
new_content = content
for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content):
img = m.group(1)
repo_part = img.split(":")[0] if ":" in img else img
# Only replace if no variable reference
if "${" not in img:
new_content = new_content.replace(img, f"{repo_part}:{tag}", 1)
jobs.log(qq, "dim", f" {repo_part}:{tag}")
break # Only first service for now
cf.write_text(new_content)
jobs.log(qq, "ok", "Image tags aangepast")
# Data dir
try:
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True)
except Exception:
pass
# Start
jobs.log(qq, "section", "Starten")
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst)
jobs.log(qq, "ok" if rc == 0 else "warn",
"Gestart" if rc == 0 else "Start mislukt")
audit.log("store", "install", "ok", ref=inst,
detail={"env": list(env_overrides.keys()), "tags": image_tags})
jobs.finish(jid, "done")
except PermissionError as e:
jobs.log(qq, "error", f"Geen schrijfrechten: {e}")
jobs.finish(jid, "error")
except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Module Store ──────────────────────────────────────────────────────────────
@app.route("/api/modules")
def api_modules():
active = cfg.load().get("ACTIVE_MODULES")
out = []
for m in MODULES:
en = active is None or m.ID in (active or [])
info = {**m.info(), "enabled": en}
js = _mod_js(m)
if js:
info["js"] = f"/api/modules/{m.ID}/js"
out.append(info)
return jsonify(out)
@app.route("/api/modules/<mid>/toggle", methods=["POST"])
def api_mod_toggle(mid):
en = (request.json or {}).get("enabled", False)
c = cfg.load()
active = list(c.get("ACTIVE_MODULES") or [m.ID for m in MODULES])
if en and mid not in active:
active.append(mid)
elif not en and mid in active:
active.remove(mid)
cfg.patch({"ACTIVE_MODULES": active})
return jsonify(ok=True)
@app.route("/api/modules/<mid>/js")
def api_mod_js(mid):
for m in MODULES:
if m.ID == mid:
js = _mod_js(m)
if js:
return Response(js.read_text(), mimetype="application/javascript")
return Response("", 404, mimetype="application/javascript")
@app.route("/api/modules/store")
def api_mod_store():
c = cfg.load()
installed = {}
if USER_MOD.exists():
for d in USER_MOD.iterdir():
if d.is_dir() and (d / "__init__.py").exists() and (d / "module.json").exists():
mid = d.name
if mid in CORE:
continue
meta = {}
try:
meta = json.loads((d / "module.json").read_text())
except Exception:
pass
installed[mid] = meta.get("version", "0.0.0")
available = {}
for repo in c.get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] not in CORE and mod["id"] not in available:
available[mod["id"]] = mod
result = []
for mid, mod in available.items():
is_installed = mid in installed
mod["installed"] = is_installed
if is_installed:
local_ver = installed[mid]
repo_ver = mod.get("version", "0.0.0")
mod["installed_version"] = local_ver
mod["repo_version"] = repo_ver
if _ver_newer(repo_ver, local_ver):
mod["status"] = "update"
else:
mod["status"] = "installed"
else:
mod["status"] = "available"
result.append(mod)
for mid, ver in installed.items():
if mid not in available and mid not in CORE:
result.append({"id": mid, "name": mid, "icon": "🧩", "version": ver,
"installed_version": ver,
"desc": "", "installed": True, "status": "orphan"})
return jsonify(modules=sorted(result, key=lambda m: m.get("name", "")))
def _ver_newer(repo_ver: str, local_ver: str) -> bool:
"""Vergelijk semver strings: True als repo_ver nieuwer is dan local_ver."""
def _parse(v):
try:
return tuple(int(x) for x in v.replace("v", "").split(".")[:3])
except (ValueError, AttributeError):
return (0, 0, 0)
return _parse(repo_ver) > _parse(local_ver)
@app.route("/api/modules/install", methods=["POST"])
def api_mod_install():
mid = (request.json or {}).get("id", "").strip()
if not mid or mid in CORE:
return jsonify(ok=False, msg="niet mogelijk")
src = None
for repo in cfg.load().get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] == mid:
src = Path(mod["_src"])
break
if src:
break
if not src:
return jsonify(ok=False, msg="niet gevonden in repo")
# Valideer: heeft module.json?
if not (src / "module.json").exists():
return jsonify(ok=False, msg=f"'{mid}' heeft geen module.json — geen geldige module")
# Valideer: heeft Module subclass?
from core.modules import _load_detail
cls, load_error = _load_detail(mid, src)
if not cls:
# Toon de __init__.py inhoud voor debug
init_content = ""
try:
init_content = (src / "__init__.py").read_text()[:500]
except Exception:
init_content = "(kon niet lezen)"
return jsonify(ok=False,
msg=f"'{mid}' kon niet geladen worden: {load_error}",
debug={"src": str(src),
"init_preview": init_content,
"files": [f.name for f in src.iterdir()][:20]})
dest = USER_MOD / mid
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(str(src), str(dest))
active = cfg.load().get("ACTIVE_MODULES")
if active is not None:
active = list(active)
if mid not in active:
active.append(mid)
cfg.patch({"ACTIVE_MODULES": active})
audit.log("modules", "install", "ok", ref=mid)
ok, msg = _notify_restart(mid)
return jsonify(ok=True, hot=ok, msg=msg)
@app.route("/api/modules/uninstall", methods=["POST"])
def api_mod_uninstall():
mid = (request.json or {}).get("id", "").strip()
if mid in CORE:
return jsonify(ok=False, msg="core kan niet verwijderd worden")
dest = USER_MOD / mid
if not dest.exists():
return jsonify(ok=False, msg="niet gevonden")
shutil.rmtree(dest)
active = cfg.load().get("ACTIVE_MODULES")
if active is not None:
cfg.patch({"ACTIVE_MODULES": [m for m in active if m != mid]})
audit.log("modules", "uninstall", "ok", ref=mid)
return jsonify(ok=True, msg="verwijderd — herstart vereist")
@app.route("/api/modules/update", methods=["POST"])
def api_mod_update():
"""Update een module naar de nieuwste versie uit de repo. Behoudt settings."""
mid = (request.json or {}).get("id", "").strip()
if not mid or mid in CORE:
return jsonify(ok=False, msg="niet mogelijk")
# Zoek nieuwste versie in repo
src = None
for repo in cfg.load().get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] == mid:
src = Path(mod["_src"])
break
if src:
break
if not src:
return jsonify(ok=False, msg="niet gevonden in repo")
# Valideer
from core.modules import _load_detail
cls, err = _load_detail(mid, src)
if not cls:
return jsonify(ok=False, msg=f"update fout: {err}")
# Lees oude + nieuwe versie
dest = USER_MOD / mid
old_ver = "?"
if dest.exists():
try:
old_meta = json.loads((dest / "module.json").read_text())
old_ver = old_meta.get("version", "?")
except Exception:
pass
new_ver = "?"
try:
new_meta = json.loads((src / "module.json").read_text())
new_ver = new_meta.get("version", "?")
except Exception:
pass
# Overschrijf bestanden (settings in MODULE_SETTINGS blijven bewaard)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(str(src), str(dest))
audit.log("modules", "update", "ok", ref=f"{mid} {old_ver}{new_ver}")
return jsonify(ok=True, msg=f"Bijgewerkt {old_ver}{new_ver} — herstart vereist",
old_version=old_ver, new_version=new_ver)
# ── Module repos ──────────────────────────────────────────────────────────────
@app.route("/api/modrepos")
def api_modrepos():
repos = cfg.load().get("MODULE_REPOS", [])
return jsonify([{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos])
@app.route("/api/modrepos", methods=["POST"])
def api_modrepos_add():
d = request.json or {}
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("MODULE_REPOS", []))
existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", ""))[:30]
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "") or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"),
"subdir": d.get("subdir", "")})
cfg.patch({"MODULE_REPOS": repos})
return jsonify(ok=True)
@app.route("/api/modrepos/<rid>", methods=["DELETE"])
def api_modrepos_del(rid):
repos = [r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] != rid]
cfg.patch({"MODULE_REPOS": repos})
c = git.cache_dir(rid)
if c.exists():
shutil.rmtree(c)
return jsonify(ok=True)
@app.route("/api/modrepos/<rid>/sync", methods=["GET", "POST"])
def api_modrepos_sync(rid):
repo = next((r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] == rid), None)
if not repo:
return jsonify(ok=False, msg="niet gevonden")
jid, q = jobs.create(f"modsync:{rid}")
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Settings ──────────────────────────────────────────────────────────────────
@app.route("/api/settings")
def api_settings():
c = cfg.load()
hide = {"WIZARD_DONE", "ACTIVE_MODULES"}
return jsonify({k: v for k, v in c.items() if k not in hide})
@app.route("/api/settings", methods=["PUT"])
def api_settings_put():
d = request.json or {}
hide = {"WIZARD_DONE", "ACTIVE_MODULES"}
updates = {k: v for k, v in d.items() if k not in hide}
if updates:
cfg.patch(updates)
return jsonify(ok=True)
@app.route("/api/settings/panels")
def api_settings_panels():
panels = []
for m in MODULES:
h = m.settings_html()
mc = cfg.load().get("MODULE_SETTINGS", {}).get(m.ID, {})
# Lees settings schema uit module.json
schema = {}
src = getattr(m, "_src", None)
if src:
mf = Path(src) / "module.json"
if mf.exists():
try:
meta = json.loads(mf.read_text())
schema = meta.get("settings", {})
except Exception:
pass
panels.append({
"id": m.ID, "name": m.NAME or m.ID, "icon": m.ICON,
"has_html": bool(h and h.strip()), "html": h or "",
"config": mc, "schema": schema,
})
return jsonify(panels=panels)
@app.route("/api/modules/<mid>/settings")
def api_mod_settings_get(mid):
mc = cfg.load().get("MODULE_SETTINGS", {}).get(mid, {})
return jsonify(config=mc)
@app.route("/api/modules/<mid>/settings", methods=["PUT", "POST"])
def api_mod_settings_put(mid):
d = request.json or {}
c = cfg.load()
ms = dict(c.get("MODULE_SETTINGS", {}))
ms[mid] = d
cfg.patch({"MODULE_SETTINGS": ms})
audit.log("modules", "settings", "ok", ref=mid)
return jsonify(ok=True)
# ── Audit ─────────────────────────────────────────────────────────────────────
@app.route("/api/audit")
def api_audit():
return jsonify(entries=audit.query(200), total=audit.count())
@app.route("/api/audit/clear", methods=["POST"])
def api_audit_clear():
audit.clear()
audit.log("audit", "clear", "ok")
return jsonify(ok=True)
# ── i18n ──────────────────────────────────────────────────────────────────────
@app.route("/api/i18n")
def api_i18n():
return jsonify(languages=i18n.available())
@app.route("/api/i18n/<lang>")
def api_i18n_lang(lang):
return jsonify(strings=i18n.strings(lang))
# ── Wizard ────────────────────────────────────────────────────────────────────
@app.route("/api/wizard")
def api_wizard():
c = cfg.load()
return jsonify(done=bool(c.get("WIZARD_DONE")),
defaults={k: c.get(k) for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR",
"APP_REPOS", "MODULE_REPOS")})
@app.route("/api/wizard/paths", methods=["GET", "POST"])
def api_wizard_paths():
if request.method == "GET":
return jsonify(ok=True, msg="use POST")
try:
d = request.json or {}
updates = {}
errors = []
for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
if k in d and d[k]:
updates[k] = d[k]
try:
Path(d[k]).mkdir(parents=True, exist_ok=True)
except Exception as e:
errors.append(f"{d[k]}: {e}")
if updates:
cfg.patch(updates)
return jsonify(ok=True, errors=errors)
except Exception as e:
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/repos", methods=["GET", "POST"])
def api_wizard_repos():
"""Sla repos op vanuit de wizard."""
if request.method == "GET":
return jsonify(ok=True, msg="use POST")
try:
d = request.json or {}
updates = {}
app_repos = d.get("APP_REPOS")
if app_repos is not None:
updates["APP_REPOS"] = [r for r in app_repos if r.get("url", "").strip()]
mod_repos = d.get("MODULE_REPOS")
if mod_repos is not None:
updates["MODULE_REPOS"] = [r for r in mod_repos if r.get("url", "").strip()]
if updates:
cfg.patch(updates)
audit.log("wizard", "repos", "ok",
detail={"app": len(updates.get("APP_REPOS", [])),
"mod": len(updates.get("MODULE_REPOS", []))})
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/sync", methods=["GET", "POST"])
def api_wizard_sync():
jid, q = jobs.create("wizard:sync")
def _run(jid):
qq = jobs.get_queue(jid)
c = cfg.load()
ok_all = True
for label, repos in [("App", c.get("APP_REPOS", [])),
("Module", c.get("MODULE_REPOS", []))]:
for repo in repos:
if not repo.get("url"):
continue
jobs.log(qq, "section", f"{label}: {repo.get('name', repo['id'])}")
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
if not ok:
ok_all = False
jobs.finish(jid, "done" if ok_all else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/wizard/git", methods=["POST"])
def api_wizard_git():
"""Verwerk de eigen-git stap van de wizard."""
d = request.json or {}
action = d.get("action", "skip")
if action == "add":
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
slug = url.rstrip("/").split("/")[-1].removesuffix(".git") or "eigen-git"
repo = {"id": slug, "url": url, "branch": "main"}
if d.get("token"):
repo["token"] = d["token"].strip()
if d.get("subdir"):
repo["subdir"] = d["subdir"].strip()
c = cfg.load()
repos = c.get("APP_REPOS", [])
if not any(r.get("url") == url for r in repos):
repos.append(repo)
cfg.patch({"APP_REPOS": repos})
audit.log("wizard", "git_add", "ok", detail={"url": url})
return jsonify(ok=True)
if action == "install_forgejo":
su_repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == "server-up"), None)
if not su_repo:
return jsonify(ok=False, msg="server-up repo niet geconfigureerd")
cache = git.cache_dir("server-up")
subdir = su_repo.get("subdir", "apps")
src = (cache / subdir / "forgejo").resolve() if subdir else (cache / "forgejo").resolve()
if not src.is_dir():
ok_sync, _ = git.clone_or_pull(su_repo)
if not ok_sync or not src.is_dir():
return jsonify(ok=False, msg="Forgejo boilerplate niet gevonden — sync mislukt")
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
dest = (lib / "forgejo").resolve()
if dest.exists():
return jsonify(ok=False, msg="'forgejo' bestaat al in de library")
try:
lib.mkdir(parents=True, exist_ok=True)
except Exception as e:
return jsonify(ok=False, msg=str(e))
port_web = int(d.get("port_web") or 3000)
port_ssh = int(d.get("port_ssh") or 2222)
data_dir = d.get("data_dir", cfg.load().get("DATA_DIR", "/opt/serverup/appdata"))
values = {
"service_name": "forgejo",
"data_dir": data_dir,
"timezone": "Europe/Amsterdam",
"port_web": port_web,
"port_ssh": port_ssh,
}
jid, q = jobs.create("wizard:install-forgejo")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", "Forgejo installeren")
boilerplates.render_to_dir(src, dest, values)
jobs.log(qq, "ok", f"Gerenderd naar {dest}")
ok, msg = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("wizard", "install_forgejo", "ok" if ok else "error", ref="forgejo")
jobs.finish(jid, "done" if ok else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
return jsonify(ok=True)
@app.route("/api/wizard/complete", methods=["GET", "POST"])
def api_wizard_done():
cfg.patch({"WIZARD_DONE": True})
audit.log("wizard", "complete", "ok")
return jsonify(ok=True)
@app.route("/api/wizard/reset", methods=["GET", "POST"])
def api_wizard_reset():
c = cfg.load()
c.pop("WIZARD_DONE", None)
cfg.save(c)
return jsonify(ok=True)
# ── Jobs ──────────────────────────────────────────────────────────────────────
@app.route("/api/debug/cache")
@app.route("/api/debug/cache/<rid>")
def api_debug_cache(rid=None):
"""Debug: toon cache structuur."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
if rid is None:
# Toon alle caches
cache_root = git.CACHE
if not cache_root.exists():
return jsonify(exists=False, path=str(cache_root))
repos = []
for d in sorted(cache_root.iterdir()):
if d.is_dir():
repos.append({"id": d.name, "path": str(d),
"contents": [f.name for f in sorted(d.iterdir()) if f.is_dir() and f.name != ".git"][:20]})
return jsonify(cache_path=str(cache_root), repos=repos)
cache = git.cache_dir(rid)
if not cache.exists():
return jsonify(exists=False, path=str(cache))
dirs = []
for p in sorted(cache.rglob("*")):
if p.is_dir() and ".git" not in str(p):
rel = str(p.relative_to(cache))
has_comp = docker.has_compose(p)
files = [f.name for f in p.iterdir() if f.is_file()][:10]
dirs.append({"path": rel, "has_compose": has_comp, "files": files})
return jsonify(exists=True, path=str(cache), dirs=dirs[:100])
@app.route("/api/debug/config")
def api_debug_config():
"""Debug: toon volledige config."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
return jsonify(cfg.load())
@app.route("/api/debug/modules")
def api_debug_modules():
"""Debug: probeer alle modules uit repo te laden en toon resultaat."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
from core.modules import _load_detail
results = []
c = cfg.load()
for repo in c.get("MODULE_REPOS", []):
cache = git.cache_dir(repo["id"])
subdir = repo.get("subdir", "")
base = cache / subdir if subdir else cache
if not base.exists():
results.append({"repo": repo["id"], "error": f"pad bestaat niet: {base}"})
continue
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith((".", "_")):
continue
has_json = (d / "module.json").exists()
has_init = (d / "__init__.py").exists()
init_preview = ""
if has_init:
try:
init_preview = (d / "__init__.py").read_text()[:300]
except Exception:
pass
cls, error = _load_detail(d.name, d) if has_init else (None, "geen __init__.py")
results.append({
"dir": d.name,
"path": str(d),
"has_module_json": has_json,
"has_init": has_init,
"init_preview": init_preview,
"load_ok": cls is not None,
"load_error": error,
"class_name": cls.__name__ if cls else None,
})
return jsonify(results=results)
@app.route("/api/debug/startup")
def api_debug_startup():
"""Debug: toon startup module loading log."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
return jsonify(log=STARTUP_LOG, loaded=[m.ID for m in MODULES])
@app.route("/api/jobs/<jid>")
def api_job(jid):
off = int(request.args.get("offset", 0))
return jsonify(jobs.stream(jid, off))
# ── Boot helpers ─────────────────────────────────────────────────────────────
def _apply_boot_repos():
"""Sla SU_BOOT_REPOS repos op in config.json als ze er nog niet in staan.
Zorgt dat repos uit de env-var ook na een herstart beschikbaar blijven,
ook als SU_BOOT_REPOS later verwijderd wordt.
"""
raw = os.environ.get("SU_BOOT_REPOS", "").strip()
if not raw:
return
try:
extra = json.loads(raw)
except Exception as e:
print(f" [boot] SU_BOOT_REPOS parse fout: {e}")
return
c = cfg.load()
existing_ids = {r.get("id") for r in c.get("APP_REPOS", [])}
new_repos = [r for r in extra
if isinstance(r, dict) and r.get("id") and r["id"] not in existing_ids]
if new_repos:
cfg.patch({"APP_REPOS": list(c.get("APP_REPOS", [])) + new_repos})
for r in new_repos:
print(f" [boot] repo opgeslagen: {r.get('id')} ({r.get('url', '')})")
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
def _boot_sync():
"""Sync alle geconfigureerde repos bij elke start (achtergrond-thread).
Loopt na een korte pauze zodat Flask volledig opgestart is.
Bij eerste start worden repos gecloned; bij herstart wordt git pull gedraaid.
"""
import time
time.sleep(2)
c = cfg.load()
for label, repo_list in [("App", c.get("APP_REPOS", [])),
("Module", c.get("MODULE_REPOS", []))]:
for repo in repo_list:
if not repo.get("url"):
continue
rid = repo.get("id", "?")
try:
ok, msg = git.clone_or_pull(repo, log_fn=None)
status = "ok" if ok else "error"
print(f" [boot] sync {label} {rid}: {status}"
+ (f"{msg[:80]}" if not ok else ""))
audit.log("core", "boot_sync", status, ref=rid,
detail=msg[:100] if not ok else "")
except Exception as e:
print(f" [boot] sync {label} {rid}: fout — {e}")
audit.log("core", "boot_sync", "error", ref=rid, detail=str(e))
# ══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
print(f"\n🚀 Server Up · http://0.0.0.0:{port}\n")
audit.init()
i18n.load()
print("Boot repos:")
_apply_boot_repos()
print("Modules:")
load_modules()
if not MODULES:
print(" (geen)")
audit.log("core", "startup", "ok")
print("Boot sync (achtergrond):")
jobs.run(_boot_sync)
print(f"\n→ http://localhost:{port}\n")
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)