diff --git a/README.md b/README.md index e69de29..d6a652c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,215 @@ +🚀 TUI-Ansible-Installer + +Ein Terminal User Interface (TUI) zum Verwalten, AusfĂŒhren und Installieren von Ansible-Playbooks auf einem lokalen oder entfernten System. + +📌 Übersicht + +Der TUI-Ansible-Installer ist ein Werkzeug, das auf einem Raspberry Pi oder jedem anderen Linux-System lĂ€uft. +Er dient als zentrale VerwaltungsoberflĂ€che fĂŒr Ansible-Playbooks und Zielsysteme. +Die TUI liest Playbooks aus der lokalen Playbook-Struktur ein, erlaubt die Auswahl eines Zielsystems und unterstĂŒtzt die automatische Einrichtung der SSH-SchlĂŒsselverbindung. + +🎯 Ziele des Projekts + +Eine einfache, intuitive TUI zur Auswahl und AusfĂŒhrung von Playbooks + +Verwaltung lokaler oder externer Zielsysteme + +Automatische Vorbereitung des Zielsystems (SSH-Key-Setup, AbhĂ€ngigkeiten, etc.) + +Automatische SelbstprĂŒfung des Installers → Installiert fehlende AbhĂ€ngigkeiten + +Playbooks in einer ĂŒbersichtlichen Tree-Struktur darstellen + +Optionales Synchronisieren von Dateien aus /playbook/data zum Zielsystem per SCP + +Minimal invasiv: Dateien in /playbook/data werden nicht in der TUI angezeigt + +📁 Verzeichnisstruktur +/playbook/ +├── roles/ +├── playbooks/ +│ ├── system/ +│ │ ├── update.yml +│ │ └── upgrade.yml +│ ├── docker/ +│ │ └── install.yml +│ └── ... +└── data/ + ├── templates/ + ├── binaries/ + └── ... + +✹ Bedeutung der Ordner + +/playbook/playbooks/ +EnthĂ€lt die Playbooks und deren Ordnerstruktur → wird 1:1 als Tree im TUI angezeigt + +/playbook/data/ +EnthĂ€lt Dateien, die ein Playbook benötigt (z. B. Konfigs, Installers, Template-Dateien). +→ Nicht im TUI sichtbar, aber vom Installer fĂŒr SCP-Transfers nutzbar. + +đŸ–„ïž Funktionen der TUI +🔍 1. Autocheck beim Start + +Beim Start prĂŒft die TUI automatisch, ob alle benötigten Komponenten installiert sind: + +Ansible + +Python3 + +pip-Pakete + +SSH-Client + +SCP / rsync (optional) + +Netzwerkverbindung + +Wenn etwas fehlt → automatische Abfrage: + +„Das System ist noch nicht vorbereitet. Soll ich die fehlenden Komponenten installieren? +[Ja] / Nein“ + +Ja (Default): System wird vorbereitet + +Nein: Nutzer kann das TUI-MenĂŒ trotzdem verwenden, aber keine Playbooks ausrollen + +đŸ—‚ïž 2. Playbook-Browser + +Durchsucht /playbook/playbooks + +Stellt die Struktur als baumartige Liste dar + +Playbooks können einzeln ausgewĂ€hlt und ausgefĂŒhrt werden + +Mehrfachauswahl möglich + +Optional: Markieren von Playbooks fĂŒr „automatische Aktualisierung“ eines Zielsystems + +🖧 3. Server-/Zielsystemverwaltung + +Über die Taste S (oder per Dateinavigation): + +✏ Server-MenĂŒ + +Neues Zielsystem hinzufĂŒgen + +Hostname / IP + +Anzeigename + +Benutzername + +Passwort (optional) + +Option: „SSH-Key automatisch einrichten“ + +Zielsystem auswĂ€hlen + +Zielsystem löschen + +Zielsystem als „Standard“ setzen + +🔐 SSH-Key-Setup + +Bei aktivierter Option konfiguriert die TUI automatisch: + +Verbindung zum Zielsystem aufbauen + +System prĂŒfen (SSH, Python, Sudo, etc.) + +SSH-Key transferieren + +Passwort-Login optional deaktivieren + +Host in ~/.ssh/known_hosts eintragen + +Testverbindung herstellen + +🧰 4. AusfĂŒhren von Playbooks + +Nach Auswahl eines Zielsystems kann der Nutzer: + +Playbook(s) auswĂ€hlen + +Option: Dateien aus /playbook/data synchronisieren + +Playbook sofort ausrollen + +oder Zielsystem markieren: „Immer aktuell halten“ + +Bei AusfĂŒhrung zeigt die TUI: + +Live-Output (stdout) + +Fehlermeldungen + +Fortschrittsbalken + +Erfolgsstatus + +đŸ–„ïž 5. Lokaler Modus (Raspberry Pi selbst) + +Der Installer kann den Raspberry Pi selbst als Ansible-Host konfigurieren: + +Installation aller AbhĂ€ngigkeiten + +Einrichtung des lokalen Inventars + +Automatische SystemprĂŒfung + +Optional: Playbooks direkt auf dem lokalen System ausfĂŒhren + +„Lokaler Modus“ kann jederzeit im ServermenĂŒ ausgewĂ€hlt werden. + +🧭 Bedienkonzept +Tastenbelegung (Vorschlag) +Taste Funktion +↑/↓ Navigation im MenĂŒ +→ Ordner öffnen / Auswahl bestĂ€tigen +← ZurĂŒck +S Server-/Zielsystemverwaltung +Enter Playbook starten / MenĂŒpunkt auswĂ€hlen +Space Playbook markieren +Q Beenden +⚡ Workflow-Beispiel + +TUI starten → Autocheck + +System vorbereiten (optional) + +Server mit S hinzufĂŒgen + +SSH-Key automatisch einrichten + +Playbooks durchsuchen + +Playbook auswĂ€hlen + +Ausrollen starten + +Status direkt im TUI sehen + +📝 Geplante Erweiterungen (optional) + +Logging/Reporting im /var/log/tui-ansible/ + +Plugin-System: eigene Module fĂŒr MenĂŒfunktionen + +Profile pro Zielsystem + +Zeitgesteuertes Ausrollen + +Git-Pull von Playbooks direkt in der TUI + +📩 Voraussetzungen + +Linux-System (Raspberry Pi empfohlen) + +Python 3.9+ + +Ansible + +SSH/SCP + +Bibliothek fĂŒr TUI (z. B. textual, urwid, rich) diff --git a/app.py b/app.py new file mode 100644 index 0000000..de05d09 --- /dev/null +++ b/app.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +import curses +import json +import os +import subprocess +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import List, Tuple + +from deps import check_dependencies, install_dependencies + + +PLAYBOOK_ROOT = Path("/playbook") +DATA_DIR_NAME = "data" +CONFIG_DIR = Path("config") +HOSTS_FILE = CONFIG_DIR / "hosts.json" + + +@dataclass +class TargetHost: + name: str # Anzeigename + host: str # Host/IP + user: str # Benutzername + password: str # (optional, eher fĂŒr SSH-Key-Setup gedacht) + ssh_key_setup: bool + is_local: bool = False + + +def load_hosts() -> List[TargetHost]: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if not HOSTS_FILE.exists(): + # Default: nur "lokal" + hosts = [TargetHost(name="lokal", host="localhost", user=os.getenv("USER", "pi"), + password="", ssh_key_setup=False, is_local=True)] + save_hosts(hosts) + return hosts + + with HOSTS_FILE.open("r", encoding="utf-8") as f: + data = json.load(f) + hosts = [TargetHost(**item) for item in data] + # Falls keine lokalen dabei sind, Standard hinzufĂŒgen + if not any(h.is_local for h in hosts): + hosts.append(TargetHost(name="lokal", host="localhost", + user=os.getenv("USER", "pi"), + password="", ssh_key_setup=False, is_local=True)) + return hosts + + +def save_hosts(hosts: List[TargetHost]) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with HOSTS_FILE.open("w", encoding="utf-8") as f: + json.dump([asdict(h) for h in hosts], f, indent=2) + + +def collect_playbooks(root: Path) -> List[Tuple[str, Path, int]]: + """ + Sucht nach .yml/.yaml unterhalb von /playbook, + ignoriert /playbook/data. + + RĂŒckgabe: Liste aus (Anzeigetext, voller Pfad, Level) + """ + playbooks: List[Tuple[str, Path, int]] = [] + if not root.exists(): + return playbooks + + for dirpath, dirnames, filenames in os.walk(root): + # /playbook/data ignorieren + if Path(dirpath).name == DATA_DIR_NAME and Path(dirpath).parent == root: + # Unterordner von /playbook/data auch ĂŒberspringen + dirnames[:] = [] + continue + + rel = Path(dirpath).relative_to(root) + level = 0 if rel == Path(".") else len(rel.parts) + + for filename in filenames: + if not (filename.endswith(".yml") or filename.endswith(".yaml")): + continue + full_path = Path(dirpath) / filename + indent = " " * level + display = f"{indent}{filename}" + playbooks.append((display, full_path, level)) + return playbooks + + +def run_playbook(playbook_path: Path, target: TargetHost, stdscr) -> None: + """ + FĂŒhrt ansible-playbook aus und zeigt die Ausgabe in der TUI. + Sehr einfache Variante, synchron/blockierend. + """ + stdscr.clear() + stdscr.addstr(0, 0, f"Starte Playbook: {playbook_path}") + stdscr.addstr(1, 0, f"Ziel: {target.name} ({'lokal' if target.is_local else target.host})") + stdscr.refresh() + + cmd = ["ansible-playbook", str(playbook_path)] + + # FĂŒr Remote-Target könnte man ein Inventory oder --limit host ergĂ€nzen, + # hier nur eine sehr simple Beispiel-Variante: + if not target.is_local: + # Erwartung: target.host ist im Inventory definiert + cmd.extend(["-l", target.host]) + + # Ausgabe direkt anzeigen + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except FileNotFoundError: + stdscr.addstr(3, 0, "Fehler: ansible-playbook wurde nicht gefunden.") + stdscr.getch() + return + + row = 3 + max_y, max_x = stdscr.getmaxyx() + for line in proc.stdout: + # Wenn zu weit unten, etwas nach oben "scrollen" + if row >= max_y - 1: + stdscr.scroll(1) + row = max_y - 2 + stdscr.addstr(row, 0, line[: max_x - 1]) + row += 1 + stdscr.refresh() + + proc.wait() + stdscr.addstr(row + 1, 0, "Playbook abgeschlossen. Taste drĂŒcken um zurĂŒckzukehren.") + stdscr.refresh() + stdscr.getch() + + +def server_menu(stdscr, hosts: List[TargetHost], current_index: int) -> int: + """ + Einfaches Server-MenĂŒ: + - Hosts auflisten + - Auswahl mit Pfeiltasten + - Enter = Host auswĂ€hlen + - N = neuen Host hinzufĂŒgen (sehr basic, text-basiert) + Gibt den Index des ausgewĂ€hlten Hosts zurĂŒck (oder unverĂ€ndert bei Abbruch). + """ + curses.curs_set(0) + selected = current_index + while True: + stdscr.clear() + stdscr.addstr(0, 0, "Server / Zielsysteme") + stdscr.addstr(1, 0, "↑/↓: Auswahl | Enter: wĂ€hlen | n: neuen Host anlegen | q: zurĂŒck") + row = 3 + for i, host in enumerate(hosts): + prefix = "> " if i == selected else " " + label = f"{host.name} ({'lokal' if host.is_local else host.host})" + stdscr.addstr(row, 0, prefix + label) + row += 1 + + stdscr.refresh() + key = stdscr.getch() + + if key in (curses.KEY_UP, ord('k')): + selected = (selected - 1) % len(hosts) + elif key in (curses.KEY_DOWN, ord('j')): + selected = (selected + 1) % len(hosts) + elif key in (ord('q'), 27): # q oder ESC + return current_index + elif key in (curses.KEY_ENTER, 10, 13): + return selected + elif key in (ord('n'), ord('N')): + # sehr simple Eingabe ĂŒber die Statuszeile + curses.echo() + stdscr.clear() + stdscr.addstr(0, 0, "Neuen Host anlegen. Leere Eingabe = Abbruch.") + stdscr.addstr(2, 0, "Anzeigename: ") + name = stdscr.getstr(2, 13, 40).decode("utf-8").strip() + if not name: + curses.noecho() + continue + stdscr.addstr(3, 0, "Host/IP: ") + host_ip = stdscr.getstr(3, 9, 40).decode("utf-8").strip() + if not host_ip: + curses.noecho() + continue + stdscr.addstr(4, 0, "Benutzer (default: pi): ") + user = stdscr.getstr(4, 23, 40).decode("utf-8").strip() or "pi" + stdscr.addstr(5, 0, "SSH-Key automatisch einrichten? (j/N): ") + ssh_key_ans = stdscr.getstr(5, 39, 3).decode("utf-8").strip().lower() + ssh_key_setup = ssh_key_ans == "j" + + curses.noecho() + new_host = TargetHost( + name=name, + host=host_ip, + user=user, + password="", + ssh_key_setup=ssh_key_setup, + is_local=False, + ) + hosts.append(new_host) + save_hosts(hosts) + selected = len(hosts) - 1 + + +def main_tui(stdscr): + curses.curs_set(0) + stdscr.nodelay(False) + + hosts = load_hosts() + current_host_index = 0 + + playbooks = collect_playbooks(PLAYBOOK_ROOT) + selected_index = 0 if playbooks else -1 + + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + # Header + current_host = hosts[current_host_index] + header = f"TUI Ansible Installer – Ziel: {current_host.name} ({'lokal' if current_host.is_local else current_host.host})" + stdscr.addstr(0, 0, header[: max_x - 1]) + + # Hilfe + help_line = "[↑/↓] Navigieren [Enter] Playbook ausfĂŒhren [s] Server wĂ€hlen [q] Beenden" + stdscr.addstr(1, 0, help_line[: max_x - 1]) + + # Playbooks zeichnen + start_row = 3 + for i, (display, path, level) in enumerate(playbooks): + row = start_row + i + if row >= max_y - 1: + break + prefix = "➀ " if i == selected_index else " " + line = f"{prefix}{display}" + stdscr.addstr(row, 0, line[: max_x - 1]) + + if not playbooks: + stdscr.addstr(start_row, 0, f"Keine Playbooks unter {PLAYBOOK_ROOT} gefunden.") + + stdscr.refresh() + + key = stdscr.getch() + if key in (ord('q'), 27): # q oder ESC + break + if key in (curses.KEY_UP, ord('k')): + if playbooks: + selected_index = (selected_index - 1) % len(playbooks) + elif key in (curses.KEY_DOWN, ord('j')): + if playbooks: + selected_index = (selected_index + 1) % len(playbooks) + elif key in (ord('s'), ord('S')): + current_host_index = server_menu(stdscr, hosts, current_host_index) + elif key in (curses.KEY_ENTER, 10, 13): + if playbooks and selected_index >= 0: + _, pb_path, _ = playbooks[selected_index] + run_playbook(pb_path, hosts[current_host_index], stdscr) + + +def startup_check_and_run(): + # Dependencies prĂŒfen VOR curses, damit Frage klar sichtbar ist. + missing = check_dependencies() + if missing: + print("Folgende benötigte Komponenten fehlen:") + for m in missing: + print(" -", m) + ans = input("System vorbereiten und fehlende Komponenten installieren? [J/n]: ").strip().lower() + if ans in ("", "j", "ja", "y", "yes"): + install_dependencies(missing) + else: + print("AbhĂ€ngigkeiten werden NICHT automatisch installiert. Die TUI kann eingeschrĂ€nkt sein.") + input("Weiter mit Enter...") + + curses.wrapper(main_tui) + + +if __name__ == "__main__": + startup_check_and_run() diff --git a/deps.py b/deps.py new file mode 100644 index 0000000..2db9ece --- /dev/null +++ b/deps.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import shutil +import subprocess +import sys +from typing import List + +REQUIRED_BINARIES = ["ansible", "ssh", "scp"] + + +def check_dependencies() -> List[str]: + """PrĂŒft, welche Binaries fehlen.""" + missing = [] + for binary in REQUIRED_BINARIES: + if shutil.which(binary) is None: + missing.append(binary) + return missing + + +def install_dependencies(missing: List[str]) -> None: + """ + Versucht, fehlende Pakete ĂŒber apt zu installieren. + Achtung: sehr Debian/Raspberry-spezifisch – bei Bedarf anpassen. + """ + print("Starte Installation der fehlenden AbhĂ€ngigkeiten...") + cmds = [] + + if "ansible" in missing: + cmds.append(["sudo", "apt", "update"]) + cmds.append(["sudo", "apt", "install", "-y", "ansible"]) + if "ssh" in missing or "scp" in missing: + cmds.append(["sudo", "apt", "install", "-y", "openssh-client"]) + + for cmd in cmds: + print("→", " ".join(cmd)) + try: + subprocess.run(cmd, check=False) + except Exception as exc: + print(f"Fehler beim AusfĂŒhren von {' '.join(cmd)}: {exc}") + + print("AbhĂ€ngigkeiten-Installation abgeschlossen (ggf. Fehlerausgabe oben prĂŒfen).")