215 lines
6.7 KiB
Python
215 lines
6.7 KiB
Python
"""Git operations — clone, pull, scan for stacks and modules."""
|
|
import json, os, subprocess, shutil
|
|
from pathlib import Path
|
|
from core.docker import COMPOSE_NAMES
|
|
from core import boilerplates as bp
|
|
|
|
CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git"))
|
|
|
|
# Fix git "dubious ownership" — container draait als root, cache kan door andere uid zijn
|
|
_git_safe_set = False
|
|
|
|
|
|
def _ensure_git_safe():
|
|
global _git_safe_set
|
|
if not _git_safe_set:
|
|
os.environ["GIT_CONFIG_COUNT"] = "1"
|
|
os.environ["GIT_CONFIG_KEY_0"] = "safe.directory"
|
|
os.environ["GIT_CONFIG_VALUE_0"] = "*"
|
|
_git_safe_set = True
|
|
|
|
|
|
# Run immediately at import
|
|
_ensure_git_safe()
|
|
|
|
|
|
def cache_dir(repo_id: str) -> Path:
|
|
return CACHE / repo_id
|
|
|
|
|
|
def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
|
|
_ensure_git_safe()
|
|
url = repo.get("url", "").strip()
|
|
if not url:
|
|
return False, "Geen URL"
|
|
branch = repo.get("branch", "main")
|
|
token = repo.get("token", "").strip()
|
|
rid = repo.get("id", "repo")
|
|
dest = cache_dir(rid)
|
|
|
|
clone_url = url
|
|
if token and url.startswith("https://"):
|
|
host = url.split("://", 1)[1]
|
|
clone_url = f"https://x-access-token:{token}@{host}"
|
|
|
|
env = os.environ.copy()
|
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
|
|
try:
|
|
if dest.exists() and (dest / ".git").exists():
|
|
if log_fn:
|
|
log_fn(f"Pull {rid}…")
|
|
r = subprocess.run(["git", "pull", "--rebase", "--autostash"],
|
|
cwd=str(dest), capture_output=True, text=True,
|
|
env=env, timeout=60)
|
|
else:
|
|
if log_fn:
|
|
log_fn(f"Clone {url}…")
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1",
|
|
clone_url, str(dest)],
|
|
capture_output=True, text=True, env=env, timeout=120)
|
|
|
|
output = (r.stdout + "\n" + r.stderr).strip()
|
|
# Verwijder token uit output voordat het gelogd of teruggegeven wordt
|
|
safe_output = output.replace(clone_url, url) if clone_url != url else output
|
|
for line in safe_output.splitlines():
|
|
if line.strip() and log_fn:
|
|
log_fn(line)
|
|
|
|
if r.returncode != 0:
|
|
return False, safe_output[:200]
|
|
return True, "OK"
|
|
except subprocess.TimeoutExpired:
|
|
return False, "Timeout"
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
|
|
def scan_stacks(repo_id: str, lib: Path, subdir: str = "") -> list[dict]:
|
|
base = cache_dir(repo_id)
|
|
if subdir:
|
|
base = base / subdir
|
|
if not base.exists():
|
|
return []
|
|
|
|
stacks = _scan_compose_dirs(base, lib)
|
|
|
|
# Fallback: als geen stacks gevonden en geen subdir opgegeven,
|
|
# zoek automatisch in bekende submappen
|
|
if not stacks and not subdir:
|
|
for fallback in ("apps", "stacks", "docker"):
|
|
fb = cache_dir(repo_id) / fallback
|
|
if fb.exists():
|
|
stacks = _scan_compose_dirs(fb, lib)
|
|
if stacks:
|
|
break
|
|
|
|
return stacks
|
|
|
|
|
|
def scan_dir(base: Path, lib: Path) -> list[dict]:
|
|
"""Scan een lokale map voor stacks/boilerplates zonder git (voor ingebouwde apps)."""
|
|
if not base.exists():
|
|
return []
|
|
return _scan_compose_dirs(base, lib)
|
|
|
|
|
|
def _scan_compose_dirs(base: Path, lib: Path) -> list[dict]:
|
|
"""Scan één directory voor compose stacks. Detecteert zowel het klassieke
|
|
Server Up formaat (compose.yml + stack.json) als het ChristianLempa
|
|
Boilerplates formaat (template.json + files/)."""
|
|
stacks = []
|
|
for d in sorted(base.iterdir()):
|
|
if not d.is_dir() or d.name.startswith((".", "_")):
|
|
continue
|
|
|
|
is_legacy = any((d / n).exists() for n in COMPOSE_NAMES)
|
|
is_bp = bp.is_boilerplate(d)
|
|
if not (is_legacy or is_bp):
|
|
continue
|
|
|
|
if is_bp:
|
|
meta = bp.metadata(d)
|
|
# boilerplate met draft=true wordt overgeslagen tenzij expliciet
|
|
if meta.get("draft"):
|
|
continue
|
|
else:
|
|
meta = _meta(d)
|
|
meta.setdefault("format", "compose")
|
|
|
|
installed = []
|
|
if lib.exists():
|
|
for ld in lib.iterdir():
|
|
if ld.is_dir() and (ld.name == d.name or ld.name.startswith(d.name + "-")):
|
|
installed.append(ld.name)
|
|
entry = {
|
|
"dir": d.name,
|
|
"name": meta.pop("name", d.name),
|
|
"path": str(d),
|
|
"instances": sorted(installed),
|
|
}
|
|
entry.update(meta)
|
|
stacks.append(entry)
|
|
return stacks
|
|
|
|
|
|
def scan_modules(repo_id: str, subdir: str = "") -> list[dict]:
|
|
base = cache_dir(repo_id)
|
|
if subdir:
|
|
base = base / subdir
|
|
if not base.exists():
|
|
return []
|
|
|
|
# Fallback: als base geen subdirectories heeft, probeer bekende submappen
|
|
if not any(d.is_dir() for d in base.iterdir()):
|
|
if not subdir:
|
|
for fb in ("modules",):
|
|
fb_path = cache_dir(repo_id) / fb
|
|
if fb_path.exists():
|
|
base = fb_path
|
|
break
|
|
|
|
mods = []
|
|
for d in sorted(base.iterdir()):
|
|
if not d.is_dir() or d.name.startswith((".", "_")):
|
|
continue
|
|
# Vereist module.json
|
|
mf = d / "module.json"
|
|
if not mf.exists():
|
|
continue
|
|
init = d / "__init__.py"
|
|
if not init.exists():
|
|
continue
|
|
# Valideer dat __init__.py daadwerkelijk een Module subclass bevat
|
|
try:
|
|
src = init.read_text()
|
|
if "Module" not in src or "class " not in src:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
meta = {}
|
|
try:
|
|
meta = json.loads(mf.read_text())
|
|
except Exception:
|
|
pass
|
|
mid = meta.get("id", d.name)
|
|
# Skip core features
|
|
from core.modules import CORE
|
|
if mid in CORE:
|
|
continue
|
|
mods.append({
|
|
"id": mid,
|
|
"name": meta.get("name", d.name),
|
|
"icon": meta.get("icon", "🧩"),
|
|
"desc": meta.get("description", ""),
|
|
"version": meta.get("version", "1.0.0"),
|
|
"tags": meta.get("tags", []),
|
|
"author": meta.get("author", ""),
|
|
"repo_id": repo_id,
|
|
"_src": str(d),
|
|
})
|
|
return mods
|
|
|
|
|
|
def _meta(d: Path) -> dict:
|
|
for n in ("app.json", "stack.json", "meta.json"):
|
|
f = d / n
|
|
if f.exists():
|
|
try:
|
|
return json.loads(f.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"description": "", "icon": "", "tags": [], "ports": []}
|