🔒 Repository is read-only – file editing is disabled.

PaganLinux/GUI-Build/pagan-gui-build.py main

423 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pagan-gui-build.py – GUI do lokalnych buildów PaganOS + push na VPS.
(architektura Wayland-friendly: GUI jako użytkownik, root tylko dla pagsync)

Listuje pakiety z receptur (git.paganlinux.eu/recipes.git), pozwala wybrać
kolejkę, buduje SEKWENCYJNIE na PC (pagsync z VPS, rootfs lokalny – hasło
sudo pytane w okienku, bez terminala) i wysyła .pag na VPS (rsync -> podpis
GPG na serwerze -> pagsync --gen-repo).

Uruchomienie:   python3 pagan-gui-build.py

Konfiguracja (env): PAGAN_VPS_HOST, PAGAN_SSH_KEY, PAGAN_GPG_KEY,
    PAGAN_ROOTFS, PAGAN_RECIPES_URL, PAGAN_JOBS, PAGAN_GUI_WORK
"""
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import tkinter as tk
from tkinter import simpledialog, ttk

# ── Konfiguracja ──────────────────────────────────────────────────────────
HOME = os.path.expanduser("~")
WORK = os.environ.get("PAGAN_GUI_WORK", os.path.join(HOME, ".pagan-gui"))
RECIPES_URL = os.environ.get("PAGAN_RECIPES_URL", "https://git.paganlinux.eu/recipes.git")
VPS_HOST = os.environ.get("PAGAN_VPS_HOST", "root@paganlinux.eu")
SSH_KEY = os.environ.get("PAGAN_SSH_KEY", os.path.join(HOME, ".ssh", "id_ed25519_pagan"))
GPG_KEY = os.environ.get("PAGAN_GPG_KEY", "A14CBE52BE0D63F5")
REPO_STABLE_VPS = "/var/www/repo.paganlinux.eu/stable"
JOBS = os.environ.get("PAGAN_JOBS", str(os.cpu_count() or 4))
ROOTFS = os.environ.get("PAGAN_ROOTFS") or (
    "/mnt/pagan" if os.path.isdir("/mnt/pagan/usr/bin") else "/mnt/pagan-native-rootfs"
)

RECIPES = os.path.join(WORK, "recipes")
TOOLS = os.path.join(WORK, "tools")
OUTPUT = os.path.join(WORK, "output")
REPO = os.path.join(WORK, "repo")
STATE = os.path.join(WORK, "state.json")
DIAG = os.path.join(WORK, "diagnostics")
LOCK = os.path.join(WORK, "pagsync.lock")
GUILOG = os.path.join(WORK, "gui.log")
PAGBUILD = os.path.join(TOOLS, "pagbuild")
PAGSYNC = os.path.join(TOOLS, "pagsync")

SSH_OPTS = ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
            "-o", "StrictHostKeyChecking=accept-new", "-o", "IdentitiesOnly=yes",
            "-o", "UserKnownHostsFile=" + os.path.join(HOME, ".ssh", "known_hosts")]

# ── Sudo przez okienko (bez terminala, Wayland-safe) ─────────────────────
_pw = None
_pw_evt = threading.Event()
_pw_cb = None          # ustawiane przez GUI: pokaż okienko na głównym wątku


def _ask_password_ui():
    """Wywoływane na głównym wątku Tk – okienko z hasłem sudo."""
    global _pw
    d = simpledialog.askstring("Hasło sudo",
                               "pagbuild/pagsync wymaga root (chroot).\n"
                               "Podaj hasło sudo:", show="*")
    _pw = d
    _pw_evt.set()


def _sudo_authed() -> bool:
    if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0:
        return True
    global _pw
    _pw = None
    _pw_evt.clear()
    if _pw_cb:
        _pw_cb()
    return _pw_evt.wait(timeout=180) and bool(_pw)


def run_root(cmd, env=None, cwd=None):
    """Polecenie jako root przez `sudo -S` (hasło z okienka). Loguje output."""
    if not _sudo_authed():
        GUI.log("❌ Brak autoryzacji sudo – przerwano.", "err")
        return 1
    envpairs = []
    if env:
        envpairs = [f"{k}={v}" for k, v in env.items()]
    full = ["sudo", "-S", "-E", "/usr/bin/env", *envpairs, *cmd]
    GUI.log("$ (sudo) " + " ".join(cmd), "cmd")
    try:
        p = subprocess.Popen(full, cwd=cwd, stdin=subprocess.PIPE,
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             text=True, errors="replace")
        p.stdin.write((_pw or "") + "\n")
        p.stdin.flush()
        p.stdin.close()
        for line in p.stdout:
            line = line.rstrip()
            if not line:
                continue
            tag = ""
            if any(s in line for s in ("✅", "Sukces", "OK")):
                tag = "ok"
            elif any(s in line for s in ("❌", "FAIL", "Błąd", "error",
                                         "denied", "permitted", "wrong password")):
                tag = "err"
            elif any(s in line for s in ("⚠", "WARN")):
                tag = "warn"
            GUI.log(line, tag)
        p.wait()
        return p.returncode
    except Exception as e:
        GUI.log(f"❌ wyjątek: {e}", "err")
        return 1


def run(cmd, cwd=None):
    """Polecenie jako bieżący użytkownik. Loguje output."""
    GUI.log("$ " + " ".join(cmd), "cmd")
    try:
        p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT, text=True, errors="replace")
        for line in p.stdout:
            line = line.rstrip()
            if not line:
                continue
            tag = ""
            if any(s in line for s in ("✅", "Sukces", "OK")):
                tag = "ok"
            elif any(s in line for s in ("❌", "FAIL", "Błąd", "error")):
                tag = "err"
            elif any(s in line for s in ("⚠", "WARN")):
                tag = "warn"
            GUI.log(line, tag)
        p.wait()
        return p.returncode
    except Exception as e:
        GUI.log(f"❌ wyjątek: {e}", "err")
        return 1


def ssh(cmd):
    return run(["ssh", *SSH_OPTS, VPS_HOST] + cmd)


def ensure_dirs():
    try:
        for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")):
            os.makedirs(d, exist_ok=True)
        probe = os.path.join(WORK, ".wtest")
        open(probe, "w").close()
        os.unlink(probe)
    except (PermissionError, OSError):
        # WORK był tworzony przez roota (stare uruchomienia) – oddaj właściciela
        GUI.log("⚠ ~/.pagan-gui należy do roota – przejmuję (sudo)", "warn")
        run_root(["chown", "-R", f"{os.getuid()}:{os.getgid()}", WORK])
        for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")):
            os.makedirs(d, exist_ok=True)


def _flog(msg: str):
    try:
        with open(GUILOG, "a", encoding="utf-8") as fh:
            fh.write(msg + "\n")
    except OSError:
        pass


# ── Akcje ─────────────────────────────────────────────────────────────────
def sync_recipes():
    ensure_dirs()
    GUI.log("── Sync receptur ──", "hdr")
    if os.path.isdir(os.path.join(RECIPES, ".git")):
        run(["git", "-C", RECIPES, "pull", "--ff-only", "origin", "main"])
    else:
        run(["git", "clone", RECIPES_URL, RECIPES])
    if not os.path.exists(PAGBUILD) or not os.path.exists(PAGSYNC):
        GUI.log("── Pobieram pagbuild/pagsync z VPS ──", "hdr")
        for dst, src in ((PAGBUILD, "/opt/pagan-web-v2/pagbuild"),
                         (PAGSYNC, "/usr/bin/pagsync")):
            run(["scp", *SSH_OPTS, f"{VPS_HOST}:{src}", dst])
            os.chmod(dst, 0o755)
    GUI.log("✅ Sync gotowy", "ok")


def recipe_list():
    out = []
    if not os.path.isdir(RECIPES):
        return out
    for root, _dirs, files in os.walk(RECIPES):
        for fn in ("PAGBUILD.yaml", "package.yml", "recipe.yaml"):
            if fn in files:
                fp = os.path.join(root, fn)
                try:
                    txt = open(fp, encoding="utf-8", errors="replace").read()
                except OSError:
                    continue
                m = re.search(r"(?m)^pkgname:\s*['\"]?([\w+.-]+)", txt)
                v = re.search(r"(?m)^pkgver:\s*['\"]?([\w.+~-]+)", txt)
                d = re.search(r"(?m)^pkgdesc:\s*['\"]?([^\n'\"]+)", txt)
                name = m.group(1) if m else os.path.basename(root)
                cat = os.path.relpath(root, RECIPES).split(os.sep)[0]
                out.append((name, v.group(1) if v else "?", cat,
                            (d.group(1).strip() if d else ""), fp))
                break
    out.sort(key=lambda x: x[0].lower())
    return out


def build_queue(names, auto_push):
    ensure_dirs()
    # git safe.directory dla roota (pagsync robi pull w recipes jako root)
    safe_env = {"GIT_CONFIG_COUNT": "1",
                "GIT_CONFIG_KEY_0": "safe.directory",
                "GIT_CONFIG_VALUE_0": RECIPES}
    for pkg in names:
        GUI.log(f"── [{pkg}] build ──", "hdr")
        env = dict(safe_env)
        env.update({
            "PAGAN_RECIPES": RECIPES, "PAGAN_REPO": REPO,
            "PAGAN_PAGBUILD": PAGBUILD, "PAGAN_ROOTFS": ROOTFS,
            "PAGAN_STATE": STATE, "PAGAN_BUILD_OUT": OUTPUT,
            "PAGAN_DIAG": DIAG, "PAGAN_LOCK": LOCK,
            "PAGAN_DO_SIGN": "", "MAKEFLAGS": f"-j{JOBS}",
        })
        rc = run_root([PAGSYNC, "--once", "--build", pkg, "--rootfs", ROOTFS], env=env)
        if rc != 0:
            GUI.log(f"⚠ {pkg}: build FAIL (rc={rc})", "warn")
    GUI.log("🏁 Kolejka buildów zakończona", "hdr")
    if auto_push:
        push_to_vps()


def push_to_vps():
    ensure_dirs()
    stable = os.path.join(REPO, "stable")
    GUI.log("── rsync .pag na VPS ──", "hdr")
    ssh_cmd = " ".join(["ssh"] + SSH_OPTS)
    if run(["rsync", "-avz", "--update", "--mkpath", "-e", ssh_cmd,
            stable + "/", f"{VPS_HOST}:{REPO_STABLE_VPS}/"]):
        GUI.log("❌ rsync nieudany", "err")
        return
    GUI.log("── Podpis .pag na VPS (klucz zostaje na serwerze) ──", "hdr")
    ssh(["bash", "-c",
         f"cd {REPO_STABLE_VPS} && for f in *.pag; do [ -f \"$f.asc\" ] || "
         f"gpg --detach-sign --armor --batch --no-tty --local-user {GPG_KEY} \"$f\" "
         f"2>/dev/null && echo '  🔏 $f.asc'; done; true"])
    GUI.log("── repo.json (VPS) ──", "hdr")
    ssh(["bash", "-c",
         f"PAGAN_DO_SIGN=1 PAGAN_GPG_KEY={GPG_KEY} pagsync --gen-repo 2>&1 | tail -3"])
    GUI.log("✅ Push zakończony", "ok")


# ── GUI ───────────────────────────────────────────────────────────────────
class GUI:
    log_queue = queue.Queue()

    @staticmethod
    def log(msg, tag=""):
        GUI.log_queue.put((msg, tag))
        _flog(msg)

    def __init__(self, root):
        self.root = root
        self.pkgs = []
        self.busy = False
        root.title("PaganOS – lokalny build + push na VPS")
        root.geometry("1120x680")

        top = ttk.Frame(root, padding=6)
        top.pack(fill="x")
        for text, fn in (
            ("🔄 Pobierz receptury", self.cmd_sync),
            ("➕ Dodaj do kolejki", self.cmd_add),
            ("🗑 Usuń z kolejki", self.cmd_remove),
            ("🔨 Zbuduj kolejkę", lambda: self.cmd_build(False)),
            ("🔨+📤 Zbuduj i wyślij", lambda: self.cmd_build(True)),
            ("📤 Wyślij na VPS", self.cmd_push),
            ("🧹 Wyczyść kolejki", self.cmd_clear),
        ):
            ttk.Button(top, text=text, command=fn).pack(side="left", padx=2)

        self.status = tk.Label(root, text="init…", anchor="w", bg="#111", fg="#8cf", padx=8)
        self.status.pack(fill="x")

        mid = ttk.Frame(root, padding=6)
        mid.pack(fill="both", expand=True)
        left = ttk.LabelFrame(mid, text="Pakiety (recipes.git) – Ctrl/Shift: wiele")
        left.pack(side="left", fill="both", expand=True, padx=(0, 4))
        self.filter_var = tk.StringVar()
        ttk.Entry(left, textvariable=self.filter_var).pack(fill="x", padx=4, pady=(4, 2))
        self.filter_var.trace_add("write", lambda *a: self.render_pkgs())
        self.listbox = tk.Listbox(left, selectmode="extended", exportselection=False,
                                  font=("monospace", 9))
        self.listbox.pack(fill="both", expand=True, padx=4, pady=4)
        self.count_lbl = ttk.Label(left, text="")
        self.count_lbl.pack(anchor="w", padx=4)

        qf = ttk.LabelFrame(mid, text="Kolejka (kernel najpierw!)")
        qf.pack(side="left", fill="y", padx=4)
        self.queue_lb = tk.Listbox(qf, selectmode="extended", width=34, font=("monospace", 9))
        self.queue_lb.pack(fill="both", expand=True, padx=4, pady=4)

        cons = ttk.LabelFrame(root, text="Konsola")
        cons.pack(fill="both", expand=True, padx=6, pady=(0, 6))
        self.txt = tk.Text(cons, height=14, state="disabled", bg="#0d1117", fg="#ddd",
                           font=("monospace", 9), wrap="none")
        self.txt.pack(fill="both", expand=True, padx=4, pady=4)
        self.txt.tag_configure("ok", foreground="#3fb950")
        self.txt.tag_configure("err", foreground="#f85149")
        self.txt.tag_configure("warn", foreground="#d29922")
        self.txt.tag_configure("hdr", foreground="#58a6ff")
        self.txt.tag_configure("cmd", foreground="#888")

        self._taskq = queue.Queue()
        self.worker = threading.Thread(target=self._worker, daemon=True)
        self.worker.start()
        self.root.after(80, self._drain_log)
        self.cmd_sync()

    # --- wątek roboczy / log ---
    def _worker(self):
        while True:
            task, args = self._taskq.get()
            self.busy = True
            self.root.after(0, lambda: self.status.config(text="⏳ pracuję…"))
            try:
                task(*args)
            except Exception as e:
                GUI.log(f"❌ {e}", "err")
            finally:
                self.busy = False
                self._taskq.task_done()
                self.root.after(0, lambda: self.status.config(text="gotowy"))

    def _submit(self, fn, *args):
        if self.busy:
            GUI.log("⏳ Poczekaj – trwa inna operacja", "warn")
            return
        self._taskq.put((fn, args))

    def _drain_log(self):
        try:
            while True:
                msg, tag = GUI.log_queue.get_nowait()
                self.txt.config(state="normal")
                self.txt.insert("end", msg + "\n", tag)
                self.txt.see("end")
                self.txt.config(state="disabled")
        except queue.Empty:
            pass
        self.root.after(80, self._drain_log)

    # --- akcje ---
    def cmd_sync(self):
        self._submit(self._do_sync)

    def _do_sync(self):
        sync_recipes()
        self.pkgs = recipe_list()
        self.root.after(0, self.render_pkgs)

    def cmd_clear(self):
        self.queue_lb.delete(0, "end")

    def cmd_add(self):
        existing = set(self.queue_lb.get(0, "end"))
        for i in self.listbox.curselection():
            name = self.pkgs[i][0]
            if name not in existing:
                self.queue_lb.insert("end", name)

    def cmd_remove(self):
        for i in reversed(self.queue_lb.curselection()):
            self.queue_lb.delete(i)

    def cmd_build(self, auto_push):
        names = list(self.queue_lb.get(0, "end"))
        if not names:
            GUI.log("Brak pakietów w kolejce", "warn")
            return
        self._submit(build_queue, names, auto_push)

    def cmd_push(self):
        self._submit(push_to_vps)

    def render_pkgs(self):
        f = self.filter_var.get().lower()
        self.listbox.delete(0, "end")
        shown = 0
        for p in self.pkgs:
            name, ver, cat = p[0], p[1], p[2]
            if f and f not in name.lower() and f not in cat.lower():
                continue
            self.listbox.insert("end", f"{name:<32} {ver:<12} {cat}")
            shown += 1
        self.count_lbl.config(text=f"pokazano {shown} z {len(self.pkgs)}")


def main():
    global _pw_cb
    _flog("\n===== start pagan-gui-build " + __import__("datetime").datetime.now().isoformat() + " =====")

    def _hook(et, ev, tb):
        import traceback as _tb
        msg = "".join(_tb.format_exception(et, ev, tb))
        _flog("❌ NIEZŁAPANY WYJĄTEK:\n" + msg)
        print(msg, file=sys.stderr)

    sys.excepthook = _hook
    if not os.path.isdir(ROOTFS):
        print(f"⚠ Uwaga: rootfs nie istnieje: {ROOTFS}\n"
              f"   Ustaw PAGAN_ROOTFS=/ścieżka/do/rootfs")
    root = tk.Tk()
    _pw_cb = lambda: root.after(0, _ask_password_ui)
    gui = GUI(root)
    root.mainloop()


if __name__ == "__main__":
    main()