1355 lines
52 KiB
Python
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)
|