-
This commit is contained in:
227
README.md
227
README.md
@@ -1,30 +1,31 @@
|
|||||||
🚀 TUI-Ansible-Installer
|
🚀 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.
|
Wichtige Designentscheidung
|
||||||
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
|
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
|
Verzeichnisstruktur (Beispiel)
|
||||||
|
|
||||||
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/
|
/playbook/
|
||||||
├── roles/
|
├── roles/
|
||||||
├── playbooks/
|
├── playbooks/
|
||||||
@@ -39,177 +40,67 @@ Minimal invasiv: Dateien in /playbook/data werden nicht in der TUI angezeigt
|
|||||||
├── binaries/
|
├── binaries/
|
||||||
└── ...
|
└── ...
|
||||||
|
|
||||||
✨ Bedeutung der Ordner
|
Bedeutung der Ordner
|
||||||
|
|
||||||
/playbook/playbooks/
|
/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/
|
/playbook/data/
|
||||||
Enthält Dateien, die ein Playbook benötigt (z. B. Konfigs, Installers, Template-Dateien).
|
Dateien, die Playbooks benötigen (z. B. Konfigurationen, Installations‑Binaries, Templates).
|
||||||
→ Nicht im TUI sichtbar, aber vom Installer für SCP-Transfers nutzbar.
|
Diese Dateien werden nicht direkt im Playbook‑Tree angezeigt, können aber optional vor dem Ausrollen synchronisiert werden.
|
||||||
|
|
||||||
🖥️ Funktionen der TUI
|
Funktionen der TUI
|
||||||
🔍 1. Autocheck beim Start
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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
|
→ Ordner öffnen / Auswahl bestätigen
|
||||||
← Zurück
|
← Zurück
|
||||||
S Server-/Zielsystemverwaltung
|
S Server-/Zielsystemverwaltung
|
||||||
Enter Playbook starten / Menüpunkt auswählen
|
Enter Playbook starten / Menüpunkt auswählen
|
||||||
Space Playbook markieren
|
Space Playbook markieren
|
||||||
Q Beenden
|
Q Beenden
|
||||||
⚡ Workflow-Beispiel
|
|
||||||
|
|
||||||
TUI starten → Autocheck
|
Hinweise für Entwickler
|
||||||
|
|
||||||
System vorbereiten (optional)
|
- 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).
|
||||||
|
|
||||||
Server mit S hinzufügen
|
Geplante Erweiterungen
|
||||||
|
|
||||||
SSH-Key automatisch einrichten
|
- 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
|
||||||
|
|
||||||
Playbooks durchsuchen
|
Voraussetzungen
|
||||||
|
|
||||||
Playbook auswählen
|
- Linux (Raspberry Pi empfohlen)
|
||||||
|
- Python 3.9+
|
||||||
Ausrollen starten
|
- Ansible installiert
|
||||||
|
- SSH/SCP (optional rsync)
|
||||||
Status direkt im TUI sehen
|
- Bibliothek für TUI (z. B. textual, urwid, rich)
|
||||||
|
|
||||||
📝 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)
|
|
||||||
|
|||||||
285
app.py
285
app.py
@@ -1,275 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import curses
|
from __future__ import annotations
|
||||||
import json
|
import sys
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from pathlib import Path
|
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)
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
PLAYBOOK_ROOT = Path("/playbook")
|
_add_src_to_path()
|
||||||
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:
|
try:
|
||||||
proc = subprocess.Popen(
|
from tui.app import main as textual_main # type: ignore
|
||||||
cmd,
|
except Exception as exc:
|
||||||
stdout=subprocess.PIPE,
|
print("Fehler beim Laden der textual-basierten TUI:", exc)
|
||||||
stderr=subprocess.STDOUT,
|
sys.exit(1)
|
||||||
text=True,
|
textual_main()
|
||||||
)
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
startup_check_and_run()
|
main()
|
||||||
|
|||||||
8
config/config.json
Normal file
8
config/config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"playbook_root": "/playbook",
|
||||||
|
"data_subdir": "data",
|
||||||
|
"inventory": "",
|
||||||
|
"remote_data_path": "/opt/tui-data",
|
||||||
|
"sync_tool": "auto",
|
||||||
|
"logging_dir": "logs"
|
||||||
|
}
|
||||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/services/__init__.py
Normal file
0
src/services/__init__.py
Normal file
57
src/services/ansible.py
Normal file
57
src/services/ansible.py
Normal file
@@ -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} ===")
|
||||||
90
src/services/config.py
Normal file
90
src/services/config.py
Normal file
@@ -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)
|
||||||
88
src/services/deps_ext.py
Normal file
88
src/services/deps_ext.py
Normal file
@@ -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)
|
||||||
31
src/services/discovery.py
Normal file
31
src/services/discovery.py
Normal file
@@ -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
|
||||||
118
src/services/hosts.py
Normal file
118
src/services/hosts.py
Normal file
@@ -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)
|
||||||
0
src/tui/__init__.py
Normal file
0
src/tui/__init__.py
Normal file
57
src/tui/app.py
Normal file
57
src/tui/app.py
Normal file
@@ -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()
|
||||||
0
src/tui/screens/__init__.py
Normal file
0
src/tui/screens/__init__.py
Normal file
100
src/tui/screens/playbook_browser.py
Normal file
100
src/tui/screens/playbook_browser.py
Normal file
@@ -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")
|
||||||
37
src/tui/screens/run_output.py
Normal file
37
src/tui/screens/run_output.py
Normal file
@@ -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.")
|
||||||
58
src/tui/screens/server_management.py
Normal file
58
src/tui/screens/server_management.py
Normal file
@@ -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()
|
||||||
41
src/tui/screens/startup_check.py
Normal file
41
src/tui/screens/startup_check.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user