This commit is contained in:
2025-11-13 01:02:07 +01:00
parent 67e5ed3807
commit 8ce890cae5
18 changed files with 767 additions and 442 deletions

227
README.md
View File

@@ -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 AnsibleWorkflow aufsetzt.
Ziel ist nicht, eine komplett eigenständige Alternative zu Ansible zu sein, sondern eine Bedienoberfläche, die
die vorhandenen AnsiblePlaybooks, 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 AnsibleMechanismen 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 AnsibleInfrastrukturen 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 ManagementFeatures (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 PlaybookRoot (z. B. /playbook/playbooks) und zeigt die Struktur als Baum an.
- Auswahl und Ausführung von Playbooks erfolgen durch Aufruf von ansibleplaybook (mit passendem Inventory oder im connection=local Modus).
- Zielsysteme werden über das vorhandene AnsibleInventar oder über einfache, in der TUI gepflegte HostEinträge ausgewählt.
- SSHSchlüsseltransfer, Tests und optionale Synchronisation von /playbook/data werden über AnsibleMechanismen oder StandardTools (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, InstallationsBinaries, Templates).
→ Nicht im TUI sichtbar, aber vom Installer für SCP-Transfers nutzbar. Diese Dateien werden nicht direkt im PlaybookTree 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 pipModule, SSHClient und optional scp/rsync verfügbar sind.
- Bietet an, fehlende Abhängigkeiten automatisch zu installieren (mit Zustimmung des Nutzers).
Ansible 2. PlaybookBrowser
- Durchsucht die konfigurierte PlaybookRoot 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 AnsibleInventory.
- Zusätzlich kann die TUI einfache HostEinträge verwalten (Name, Host, User, optionales Passwort, SSHKeySetup).
- SSHKeySetup wird über StandardMechanismen umgesetzt (sshcopyid oder Ansible modules).
pip-Pakete 4. Ausführung von Playbooks
- Ausführung über ansibleplaybook. Wenn ein Host als lokal markiert ist, wird Verbindung als local/connection=local ausgeführt.
- LiveOutput, Fehlermeldungen und ExitStatus 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 AnsibleKommandos, 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 AnsibleAufrufen als auch gegen MockStubs laufen (für CI).
Server mit S hinzufügen Geplante Erweiterungen
SSH-Key automatisch einrichten - Logging/Reporting in /var/log/tui-ansible/
- PluginSystem für zusätzliche Menüfunktionen
- Profile pro Zielsystem
- Zeitgesteuertes Ausrollen
- GitPull 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
View File

@@ -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
View 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
View File

0
src/__init__.py Normal file
View File

0
src/services/__init__.py Normal file
View File

57
src/services/ansible.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

57
src/tui/app.py Normal file
View 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()

View File

View 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")

View 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.")

View 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()

View 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())