diff --git a/README.md b/README.md index d6a652c..23bacb6 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,31 @@ 🚀 TUI-Ansible-Installer -Ein Terminal User Interface (TUI) zum Verwalten, AusfĂŒhren und Installieren von Ansible-Playbooks auf einem lokalen oder entfernten System. +Kurzbeschreibung -📌 Übersicht +Dieses Projekt stellt eine Terminal User Interface (TUI) zur VerfĂŒgung, die auf dem bestehenden Ansible‑Workflow aufsetzt. +Ziel ist nicht, eine komplett eigenstĂ€ndige Alternative zu Ansible zu sein, sondern eine BedienoberflĂ€che, die +die vorhandenen Ansible‑Playbooks, Inventories und Konfigurationen nutzt und fĂŒr Interaktion, Auswahl und AusfĂŒhrung vereinfacht. -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. +Wichtige Designentscheidung -🎯 Ziele des Projekts +Die TUI erweitert und orchestriert Ansible; alle Aktionen werden ĂŒber Ansible (ansible-playbook, Inventory, Callback Plugins) ausgefĂŒhrt. +Änderungen an Playbooks oder Inventories sollten weiterhin ĂŒber die bekannten Ansible‑Mechanismen erfolgen (git, CI, editing). +Die TUI ĂŒbernimmt keine proprietĂ€re Konfiguration, sondern bietet eine Bedien‑ und Automatisierungsschicht fĂŒr den etablierten Workflow. -Eine einfache, intuitive TUI zur Auswahl und AusfĂŒhrung von Playbooks +Ein Sonderfall: Lokaler Modus (Standalone) -Verwaltung lokaler oder externer Zielsysteme +ZusĂ€tzlich zur Integration in bestehende Ansible‑Infrastrukturen bietet die TUI einen optionalen "Lokalen Modus". +Im Lokalen Modus kann die TUI auf einem Einzelrechner (z. B. Raspberry Pi) Playbooks installieren und ausfĂŒhren, ohne dass dieser Rechner Teil eines zentralen Verbunds sein muss +oder zusĂ€tzliche Management‑Features (z. B. von "anvbe") genutzt werden. Das ist praktisch fĂŒr Einzelinstallationen oder wenn kein zentrales Inventar vorhanden ist. -Automatische Vorbereitung des Zielsystems (SSH-Key-Setup, AbhĂ€ngigkeiten, etc.) +Kurz ĂŒber die Funktionsweise -Automatische SelbstprĂŒfung des Installers → Installiert fehlende AbhĂ€ngigkeiten +- Die TUI liest Playbooks aus einer konfigurierbaren Playbook‑Root (z. B. /playbook/playbooks) und zeigt die Struktur als Baum an. +- Auswahl und AusfĂŒhrung von Playbooks erfolgen durch Aufruf von ansible‑playbook (mit passendem Inventory oder im connection=local Modus). +- Zielsysteme werden ĂŒber das vorhandene Ansible‑Inventar oder ĂŒber einfache, in der TUI gepflegte Host‑EintrĂ€ge ausgewĂ€hlt. +- SSH‑SchlĂŒsseltransfer, Tests und optionale Synchronisation von /playbook/data werden ĂŒber Ansible‑Mechanismen oder Standard‑Tools (scp/rsync) umgesetzt. -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 +Verzeichnisstruktur (Beispiel) /playbook/ ├── roles/ ├── playbooks/ @@ -39,177 +40,67 @@ Minimal invasiv: Dateien in /playbook/data werden nicht in der TUI angezeigt ├── binaries/ └── ... -✹ Bedeutung der Ordner +Bedeutung der Ordner /playbook/playbooks/ -EnthĂ€lt die Playbooks und deren Ordnerstruktur → wird 1:1 als Tree im TUI angezeigt +Beinhaltet 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. + /playbook/data/ +Dateien, die Playbooks benötigen (z. B. Konfigurationen, Installations‑Binaries, Templates). +Diese Dateien werden nicht direkt im Playbook‑Tree angezeigt, können aber optional vor dem Ausrollen synchronisiert werden. -đŸ–„ïž Funktionen der TUI -🔍 1. Autocheck beim Start +Funktionen der TUI -Beim Start prĂŒft die TUI automatisch, ob alle benötigten Komponenten installiert sind: +1. Autocheck beim Start +- PrĂŒft, ob Ansible, Python3, erforderliche pip‑Module, SSH‑Client und optional scp/rsync verfĂŒgbar sind. +- Bietet an, fehlende AbhĂ€ngigkeiten automatisch zu installieren (mit Zustimmung des Nutzers). -Ansible +2. Playbook‑Browser +- Durchsucht die konfigurierte Playbook‑Root und zeigt Playbooks als baumartige Liste. +- Auswahl einzelner oder mehrerer Playbooks, Markierung fĂŒr wiederkehrende Aufgaben. -Python3 +3. Server‑/Zielsystemverwaltung +- Nutzen Sie bevorzugt das vorhandene Ansible‑Inventory. +- ZusĂ€tzlich kann die TUI einfache Host‑EintrĂ€ge verwalten (Name, Host, User, optionales Passwort, SSH‑Key‑Setup). +- SSH‑Key‑Setup wird ĂŒber Standard‑Mechanismen umgesetzt (ssh‑copy‑id oder Ansible modules). -pip-Pakete +4. AusfĂŒhrung von Playbooks +- AusfĂŒhrung ĂŒber ansible‑playbook. Wenn ein Host als lokal markiert ist, wird Verbindung als local/connection=local ausgefĂŒhrt. +- Live‑Output, Fehlermeldungen und Exit‑Status werden in der TUI dargestellt. +- Optional: Synchronisation von /playbook/data vor dem Ausrollen. -SSH-Client +5. Lokaler Modus +- Einrichtung des lokalen Inventars und Möglichkeit, Playbooks direkt auf dem aktuellen Rechner auszufĂŒhren. +- NĂŒtzlich, wenn der Rechner nicht in einen zentralen Verbund eingebunden werden soll oder wenn Features von "anvbe" nicht genutzt werden. -SCP / rsync (optional) +Bedienkonzept (Tasten) -Netzwerkverbindung +↑/↓ Navigation +→ Ordner öffnen / Auswahl bestĂ€tigen +← ZurĂŒck +S Server-/Zielsystemverwaltung +Enter Playbook starten / MenĂŒpunkt auswĂ€hlen +Space Playbook markieren +Q Beenden -Wenn etwas fehlt → automatische Abfrage: +Hinweise fĂŒr Entwickler -„Das System ist noch nicht vorbereitet. Soll ich die fehlenden Komponenten installieren? -[Ja] / Nein“ +- Die TUI sollte die vorhandenen Ansible‑Kommandos, Inventories und Playbooks verwenden. Keine eigenen proprietĂ€ren Formate. +- Anpassungen an Integrationen (z. B. “anvbe”) sollten optional sein, nicht Voraussetzung. +- Tests sollten sowohl gegen echten Ansible‑Aufrufen als auch gegen Mock‑Stubs laufen (fĂŒr CI). -Ja (Default): System wird vorbereitet +Geplante Erweiterungen -Nein: Nutzer kann das TUI-MenĂŒ trotzdem verwenden, aber keine Playbooks ausrollen +- Logging/Reporting in /var/log/tui-ansible/ +- Plugin‑System fĂŒr zusĂ€tzliche MenĂŒfunktionen +- Profile pro Zielsystem +- Zeitgesteuertes Ausrollen +- Git‑Pull von Playbooks aus der TUI -đŸ—‚ïž 2. Playbook-Browser +Voraussetzungen -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) +- Linux (Raspberry Pi empfohlen) +- Python 3.9+ +- Ansible installiert +- SSH/SCP (optional rsync) +- Bibliothek fĂŒr TUI (z. B. textual, urwid, rich) diff --git a/app.py b/app.py index de05d09..5efd7a6 100644 --- a/app.py +++ b/app.py @@ -1,275 +1,24 @@ #!/usr/bin/env python3 -import curses -import json -import os -import subprocess -from dataclasses import dataclass, asdict +from __future__ import annotations +import sys from pathlib import Path -from typing import List, Tuple -from deps import check_dependencies, install_dependencies +def _add_src_to_path() -> None: + root = Path(__file__).resolve().parent + src = root / "src" + if src.exists(): + p = str(src) + if p not in sys.path: + sys.path.insert(0, p) - -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 +def main() -> None: + _add_src_to_path() 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) - + from tui.app import main as textual_main # type: ignore + except Exception as exc: + print("Fehler beim Laden der textual-basierten TUI:", exc) + sys.exit(1) + textual_main() if __name__ == "__main__": - startup_check_and_run() + main() diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..12a474b --- /dev/null +++ b/config/config.json @@ -0,0 +1,8 @@ +{ + "playbook_root": "/playbook", + "data_subdir": "data", + "inventory": "", + "remote_data_path": "/opt/tui-data", + "sync_tool": "auto", + "logging_dir": "logs" +} diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/ansible.py b/src/services/ansible.py new file mode 100644 index 0000000..88e0f08 --- /dev/null +++ b/src/services/ansible.py @@ -0,0 +1,57 @@ +from __future__ import annotations +import os +import subprocess +from pathlib import Path +from typing import Iterable, Iterator, List, Optional, Tuple +from .hosts import TargetHost + + +def build_cmd(playbook_path: Path, host: TargetHost, inventory: Optional[str] = "") -> List[str]: + cmd: List[str] = ["ansible-playbook", str(playbook_path)] + if host.is_local: + # Lokalmode: Inventar auf localhost, connection=local + cmd.extend(["-i", "localhost,", "-c", "local"]) + else: + if inventory: + cmd.extend(["-i", str(inventory)]) + # System-Inventory, dann -l host zur Limitierung + cmd.extend(["-l", host.host]) + return cmd + + +def run_playbook_stream(playbook_path: Path, host: TargetHost, inventory: Optional[str] = "") -> Iterator[str]: + """ + FĂŒhrt ansible-playbook aus und liefert stdout/stderr zeilenweise. + """ + env = os.environ.copy() + # stdout Callback default fĂŒr einfache, kontinuierliche Ausgabe + env.setdefault("ANSIBLE_STDOUT_CALLBACK", "default") + cmd = build_cmd(playbook_path, host, inventory) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + except FileNotFoundError: + yield "Fehler: ansible-playbook nicht gefunden. Bitte Installation prĂŒfen." + return + + assert proc.stdout is not None + for line in proc.stdout: + yield line.rstrip("\n") + proc.wait() + yield f"[EXIT] Code: {proc.returncode}" + + +def run_multiple_playbooks_stream(paths: Iterable[Path], host: TargetHost, inventory: Optional[str] = "") -> Iterator[Tuple[Path, str]]: + """ + FĂŒhrt mehrere Playbooks nacheinander aus, streamed (path, line). + """ + for p in paths: + yield (p, f"=== Starte Playbook: {p} ===") + for line in run_playbook_stream(p, host, inventory): + yield (p, line) + yield (p, f"=== Fertig: {p} ===") diff --git a/src/services/config.py b/src/services/config.py new file mode 100644 index 0000000..46036b2 --- /dev/null +++ b/src/services/config.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import os +import json +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Dict, Any + +CONFIG_DIR = Path("config") +CONFIG_FILE = CONFIG_DIR / "config.json" + + +@dataclass +class Config: + playbook_root: Path + data_subdir: str = "data" + inventory: str = "" + remote_data_path: str = "/opt/tui-data" + sync_tool: str = "auto" # rsync|scp|auto + logging_dir: str = "logs" + + @property + def data_root(self) -> Path: + return self.playbook_root / self.data_subdir + + +def _load_file_config() -> Dict[str, Any]: + if CONFIG_FILE.exists(): + with CONFIG_FILE.open("r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def _apply_env_overrides(cfg: Dict[str, Any]) -> Dict[str, Any]: + env_map = { + "ANSIBLE_TUI_PLAYBOOK_ROOT": "playbook_root", + "ANSIBLE_TUI_DATA_SUBDIR": "data_subdir", + "ANSIBLE_TUI_INVENTORY": "inventory", + "ANSIBLE_TUI_REMOTE_DATA_PATH": "remote_data_path", + "ANSIBLE_TUI_SYNC_TOOL": "sync_tool", + "ANSIBLE_TUI_LOGGING_DIR": "logging_dir", + } + for env, key in env_map.items(): + val = os.environ.get(env) + if val: + cfg[key] = val + return cfg + + +def _with_defaults(cfg: Dict[str, Any]) -> Dict[str, Any]: + defaults = { + "playbook_root": "/playbook", + "data_subdir": "data", + "inventory": "", + "remote_data_path": "/opt/tui-data", + "sync_tool": "auto", + "logging_dir": "logs", + } + out = {**defaults, **cfg} + return out + + +def _validate(cfg: Dict[str, Any]) -> None: + root = Path(cfg["playbook_root"]).expanduser() + if not root.exists(): + # Falls nicht vorhanden, anlegen (nicht fatal) + root.mkdir(parents=True, exist_ok=True) + + +@lru_cache(maxsize=1) +def get_config() -> Config: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + file_cfg = _load_file_config() + cfg = _with_defaults(file_cfg) + cfg = _apply_env_overrides(cfg) + _validate(cfg) + playbook_root = Path(cfg["playbook_root"]).expanduser().resolve() + return Config( + playbook_root=playbook_root, + data_subdir=str(cfg["data_subdir"]), + inventory=str(cfg["inventory"]), + remote_data_path=str(cfg["remote_data_path"]), + sync_tool=str(cfg["sync_tool"]), + logging_dir=str(cfg["logging_dir"]), + ) + + +def ensure_runtime_dirs() -> None: + logs_dir = Path(get_config().logging_dir) + logs_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/services/deps_ext.py b/src/services/deps_ext.py new file mode 100644 index 0000000..7004930 --- /dev/null +++ b/src/services/deps_ext.py @@ -0,0 +1,88 @@ +from __future__ import annotations +import shutil +import subprocess +import sys +import socket +from typing import List, Dict +from deps import check_dependencies as base_check + + +def check_python_version(min_major: int = 3, min_minor: int = 9) -> bool: + return sys.version_info >= (min_major, min_minor) + + +def check_pip() -> bool: + return shutil.which("pip3") is not None or shutil.which("pip") is not None + + +def check_module_installed(mod_name: str) -> bool: + try: + __import__(mod_name) + return True + except Exception: + return False + + +def check_rsync() -> bool: + return shutil.which("rsync") is not None + + +def check_network() -> bool: + try: + socket.gethostbyname("pypi.org") + return True + except Exception: + return False + + +def check_dependencies_ext() -> Dict[str, List[str]]: + missing_bins = list(base_check()) # ansible, ssh, scp + if not check_rsync(): + missing_bins.append("rsync") + missing_pip: List[str] = [] + if not check_module_installed("textual"): + missing_pip.append("textual") + + messages: List[str] = [] + if not check_python_version(): + messages.append("Python >= 3.9 benötigt") + if not check_pip(): + messages.append("pip/pip3 nicht gefunden") + if not check_network(): + messages.append("Netzwerk/DNS Auflösung scheint nicht zu funktionieren") + + return { + "missing_bins": sorted(set(missing_bins)), + "missing_pip": sorted(set(missing_pip)), + "messages": messages, + } + + +def install_dependencies_ext(missing_bins: List[str], missing_pip: List[str]) -> None: + cmds = [] + if missing_bins: + cmds.append(["sudo", "apt", "update"]) + bin_map = { + "ansible": ["ansible"], + "ssh": ["openssh-client"], + "scp": ["openssh-client"], + "rsync": ["rsync"], + } + apt_pkgs = set() + for b in set(missing_bins): + for pkg in bin_map.get(b, [b]): + apt_pkgs.add(pkg) + cmds.append(["sudo", "apt", "install", "-y", *sorted(apt_pkgs)]) + + for cmd in cmds: + try: + subprocess.run(cmd, check=False) + except Exception as exc: + print("Fehler bei:", " ".join(cmd), exc) + + if missing_pip: + pip = shutil.which("pip3") or shutil.which("pip") or "pip3" + try: + subprocess.run([pip, "install", *missing_pip], check=False) + except Exception as exc: + print("pip install Fehler:", exc) diff --git a/src/services/discovery.py b/src/services/discovery.py new file mode 100644 index 0000000..137b16e --- /dev/null +++ b/src/services/discovery.py @@ -0,0 +1,31 @@ +from __future__ import annotations +import os +from pathlib import Path +from typing import List, Tuple + + +def discover_playbooks(root: Path, data_subdir: str = "data") -> List[Tuple[str, Path, int]]: + """ + Liefert Liste aus (Display-Text, voller Pfad, Level). + Ignoriert das data-Verzeichnis auf oberster Ebene. + """ + playbooks: List[Tuple[str, Path, int]] = [] + if not root.exists(): + return playbooks + + for dirpath, dirnames, filenames in os.walk(root): + path = Path(dirpath) + # ignore top-level data dir + if path.parent == root and path.name == data_subdir: + dirnames[:] = [] + continue + + rel = path.relative_to(root) + level = 0 if rel == Path(".") else len(rel.parts) + for fn in filenames: + if fn.endswith(".yml") or fn.endswith(".yaml"): + full = path / fn + indent = " " * level + display = f"{indent}{fn}" + playbooks.append((display, full, level)) + return playbooks diff --git a/src/services/hosts.py b/src/services/hosts.py new file mode 100644 index 0000000..ee0dd7a --- /dev/null +++ b/src/services/hosts.py @@ -0,0 +1,118 @@ +from __future__ import annotations +import json +import os +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import List, Optional + +CONFIG_DIR = Path("config") +HOSTS_FILE = CONFIG_DIR / "hosts.json" + + +@dataclass +class TargetHost: + name: str + host: str + user: str + password: str = "" + ssh_key_setup: bool = False + is_local: bool = False + is_default: bool = False + + +def _ensure_config_dir() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def save_hosts(hosts: List[TargetHost]) -> None: + _ensure_config_dir() + with HOSTS_FILE.open("w", encoding="utf-8") as f: + json.dump([asdict(h) for h in hosts], f, indent=2, ensure_ascii=False) + + +def load_hosts() -> List[TargetHost]: + _ensure_config_dir() + if not HOSTS_FILE.exists(): + user = os.getenv("USER", "pi") + hosts = [TargetHost(name="lokal", host="localhost", user=user, is_local=True, is_default=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] + + # Sicherstellen: lokaler Host vorhanden + if not any(h.is_local for h in hosts): + hosts.append(TargetHost(name="lokal", host="localhost", user=os.getenv("USER", "pi"), is_local=True)) + + # Sicherstellen: ein Default gesetzt + if not any(h.is_default for h in hosts) and hosts: + hosts[0].is_default = True + + return hosts + + +def get_default_index(hosts: Optional[List[TargetHost]] = None) -> int: + hs = hosts or load_hosts() + for i, h in enumerate(hs): + if h.is_default: + return i + return 0 if hs else -1 + + +_current_index: Optional[int] = None + + +def get_current_index() -> int: + global _current_index + hs = load_hosts() + if _current_index is None: + _current_index = get_default_index(hs) + # clamp + if not hs: + _current_index = -1 + else: + if _current_index >= len(hs): + _current_index = len(hs) - 1 + if _current_index < 0: + _current_index = 0 + return _current_index + + +def set_current_index(idx: int) -> None: + global _current_index + _current_index = idx + + +def get_current_host() -> Optional[TargetHost]: + hs = load_hosts() + idx = get_current_index() + if 0 <= idx < len(hs): + return hs[idx] + return None + + +def set_default(idx: int) -> None: + hs = load_hosts() + if not hs: + return + for i, h in enumerate(hs): + h.is_default = (i == idx) + save_hosts(hs) + + +def add_host(name: str, host: str, user: str, ssh_key_setup: bool = False, password: str = "") -> None: + hs = load_hosts() + hs.append(TargetHost(name=name, host=host, user=user, password=password, ssh_key_setup=ssh_key_setup)) + save_hosts(hs) + + +def remove_host(idx: int) -> None: + hs = load_hosts() + if 0 <= idx < len(hs): + was_default = hs[idx].is_default + del hs[idx] + if hs: + if was_default or not any(h.is_default for h in hs): + hs[0].is_default = True + save_hosts(hs) diff --git a/src/tui/__init__.py b/src/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tui/app.py b/src/tui/app.py new file mode 100644 index 0000000..1446b1d --- /dev/null +++ b/src/tui/app.py @@ -0,0 +1,57 @@ +from __future__ import annotations +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Static +from .screens.startup_check import StartupCheckScreen + + +class TuiAnsibleApp(App): + BINDINGS = [ + ("q", "quit", "Beenden"), + ("s", "server", "Server"), + ("a", "select_all", "Alle markieren"), + ("d", "toggle_sync", "Daten-Sync"), + ("h", "help", "Hilfe"), + ] + + TITLE = "TUI Ansible Installer" + SUB_TITLE = "textual" + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="body") + yield Footer() + + async def on_mount(self) -> None: + await self.push_screen(StartupCheckScreen()) + + def action_quit(self) -> None: + self.exit() + + def action_server(self) -> None: + # Wird im PlaybookBrowser umgesetzt + pass + + def action_select_all(self) -> None: + screen = self.screen + if hasattr(screen, "action_select_all"): + try: + screen.action_select_all() + except Exception: + pass + + def action_toggle_sync(self) -> None: + screen = self.screen + if hasattr(screen, "action_toggle_sync"): + try: + screen.action_toggle_sync() + except Exception: + pass + + +def main() -> None: + app = TuiAnsibleApp() + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/tui/screens/__init__.py b/src/tui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tui/screens/playbook_browser.py b/src/tui/screens/playbook_browser.py new file mode 100644 index 0000000..007e70e --- /dev/null +++ b/src/tui/screens/playbook_browser.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from pathlib import Path +from typing import List, Set, Tuple +from textual.screen import Screen +from textual.app import ComposeResult +from textual.widgets import Static, ListView, ListItem, Footer, Header +from ...services.config import get_config +from ...services.discovery import discover_playbooks +from ...services.hosts import get_current_host +from .server_management import ServerManagementScreen +from .run_output import RunOutputScreen + + +class PlaybookBrowserScreen(Screen): + BINDINGS = [ + ("space", "toggle_select", "Auswahl toggeln"), + ("a", "select_all", "Alle markieren"), + ("enter", "run", "AusfĂŒhren"), + ("s", "server", "Server"), + ("q", "app.quit", "Beenden"), + ] + + def compose(self) -> ComposeResult: + cfg = get_config() + self.header = Header() + yield self.header + host = get_current_host() + host_text = "Ziel: unbekannt" + if host: + host_text = f"Ziel: {host.name} ({lokal if host.is_local else host.host})" + self.hostline = Static(host_text, id="hostline") + yield self.hostline + yield Static(f"Playbooks unter {cfg.playbook_root}", id="title") + self.items: List[Tuple[str, Path, int]] = discover_playbooks(cfg.playbook_root, cfg.data_subdir) + self.selected: Set[int] = set() + self.list_view = ListView() + for i, (display, _path, _lvl) in enumerate(self.items): + self.list_view.append(ListItem(Static(self._render_label(i, display)))) + if not self.items: + self.list_view.append(ListItem(Static("Keine Playbooks gefunden"))) + yield self.list_view + yield Footer() + + def _render_label(self, idx: int, display: str) -> str: + mark = "[x]" if idx in self.selected else "[ ]" + return f"{mark} {display}" + + def action_toggle_select(self) -> None: + if not self.items: + return + idx = self.list_view.index or 0 + if idx >= len(self.items): + return + if idx in self.selected: + self.selected.remove(idx) + else: + self.selected.add(idx) + display, _p, _l = self.items[idx] + li = self.list_view.children[idx] + if isinstance(li, ListItem) and li.children: + w = li.children[0] + if isinstance(w, Static): + w.update(self._render_label(idx, display)) + + def action_select_all(self) -> None: + if not self.items: + return + if len(self.selected) == len(self.items): + self.selected.clear() + else: + self.selected = set(range(len(self.items))) + for i, (display, _p, _l) in enumerate(self.items): + li = self.list_view.children[i] + if isinstance(li, ListItem) and li.children: + w = li.children[0] + if isinstance(w, Static): + w.update(self._render_label(i, display)) + + def action_server(self) -> None: + self.app.push_screen(ServerManagementScreen()) + + def action_run(self) -> None: + if not self.items: + return + if not self.selected: + idx = self.list_view.index or 0 + if idx < len(self.items): + self.selected.add(idx) + host = get_current_host() + if not host: + return + paths: List[Path] = [self.items[i][1] for i in sorted(self.selected)] + self.app.push_screen(RunOutputScreen(paths, host)) + + def on_show(self) -> None: + host = get_current_host() + if host: + self.hostline.update(f"Ziel: {host.name} ({lokal if host.is_local else host.host})") + else: + self.hostline.update("Ziel: unbekannt") diff --git a/src/tui/screens/run_output.py b/src/tui/screens/run_output.py new file mode 100644 index 0000000..5cb9287 --- /dev/null +++ b/src/tui/screens/run_output.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from typing import List +from pathlib import Path +from textual.screen import Screen +from textual.app import ComposeResult +from textual.widgets import Header, Footer, Static, TextLog +from ...services.config import get_config +from ...services.hosts import TargetHost +from ...services.ansible import run_multiple_playbooks_stream + + +class RunOutputScreen(Screen): + BINDINGS = [ + ("q", "app.pop_screen", "ZurĂŒck"), + ("escape", "app.pop_screen", "ZurĂŒck"), + ] + + def __init__(self, playbooks: List[Path], host: TargetHost) -> None: + super().__init__() + self.playbooks = playbooks + self.host = host + + def compose(self) -> ComposeResult: + yield Header() + yield Static(f"Ziel: {self.host.name} ({lokal if self.host.is_local else self.host.host})", id="hostline") + self.log = TextLog(highlight=False, wrap=True) + yield self.log + yield Footer() + + async def on_mount(self) -> None: + self.call_later(self._run) + + async def _run(self) -> None: + cfg = get_config() + for p, line in run_multiple_playbooks_stream(self.playbooks, self.host, cfg.inventory or ""): + self.log.write(line) + self.log.write("Alle Playbooks beendet. DrĂŒcke q/ESC zum ZurĂŒckkehren.") diff --git a/src/tui/screens/server_management.py b/src/tui/screens/server_management.py new file mode 100644 index 0000000..d70abe4 --- /dev/null +++ b/src/tui/screens/server_management.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from textual.screen import Screen +from textual.app import ComposeResult +from textual.widgets import Header, Footer, ListView, ListItem, Static +from ...services.hosts import load_hosts, get_current_index, set_current_index, set_default, remove_host + + +class ServerManagementScreen(Screen): + BINDINGS = [ + ("q", "app.pop_screen", "ZurĂŒck"), + ("enter", "select", "AuswĂ€hlen"), + ("f", "make_default", "Als Standard setzen"), + ("delete", "delete", "Löschen"), + ] + + def compose(self) -> ComposeResult: + yield Header() + self.title = Static("Server / Zielsysteme – ↑/↓ Auswahl, Enter wĂ€hlen, f Standard, Entf löschen, q zurĂŒck") + yield self.title + self.list = ListView(id="hosts") + yield self.list + yield Footer() + + def on_mount(self) -> None: + self.refresh_list() + + def refresh_list(self) -> None: + hosts = load_hosts() + items = [] + current_idx = get_current_index() + for i, h in enumerate(hosts): + parts = [] + parts.append(">" if i == current_idx else " ") + parts.append(f"{h.name} ({lokal if h.is_local else h.host})") + if h.is_default: + parts.append("[default]") + label = " ".join(parts) + items.append(ListItem(Static(label))) + self.list.clear() + for item in items: + self.list.append(item) + if 0 <= current_idx < len(items): + self.list.index = current_idx + + def action_select(self) -> None: + idx = self.list.index or 0 + set_current_index(idx) + self.app.pop_screen() + + def action_make_default(self) -> None: + idx = self.list.index or 0 + set_default(idx) + self.refresh_list() + + def action_delete(self) -> None: + idx = self.list.index or 0 + remove_host(idx) + self.refresh_list() diff --git a/src/tui/screens/startup_check.py b/src/tui/screens/startup_check.py new file mode 100644 index 0000000..b9141dd --- /dev/null +++ b/src/tui/screens/startup_check.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from textual.screen import Screen +from textual.widgets import Button, Static +from textual.containers import Vertical +from textual.app import ComposeResult +from ...services.deps_ext import check_dependencies_ext, install_dependencies_ext +from ...services.config import get_config, ensure_runtime_dirs +from .playbook_browser import PlaybookBrowserScreen + + +class StartupCheckScreen(Screen): + def compose(self) -> ComposeResult: + self.status = Static("", id="status") + yield Vertical( + Static("Autocheck der AbhĂ€ngigkeiten", id="title"), + self.status, + Button("Installieren und fortfahren", id="install", variant="primary"), + Button("Ohne Installation fortfahren", id="continue"), + ) + + def on_mount(self) -> None: + ensure_runtime_dirs() + _ = get_config() # Konfig initialisieren + info = check_dependencies_ext() + lines = [] + if info["messages"]: + lines.extend([f"- {m}" for m in info["messages"]]) + if info["missing_bins"]: + lines.append("Fehlende Pakete: " + ", ".join(info["missing_bins"])) + if info["missing_pip"]: + lines.append("Fehlende Python-Module: " + ", ".join(info["missing_pip"])) + if not lines: + lines = ["Alle benötigten Komponenten scheinen vorhanden zu sein."] + self.status.update("\\n".join(lines)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + info = check_dependencies_ext() + if event.button.id == "install": + install_dependencies_ext(info["missing_bins"], info["missing_pip"]) + # Weiter zum Browser + self.app.push_screen(PlaybookBrowserScreen())