# Info-Board Neu - Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation ## Ziel Der Jobrunner fuehrt aus dem Admin-Backend heraus Provisionierungsjobs aus, die ein neues Display technisch in Betrieb nehmen. Dieses Dokument beschreibt: - wie ein Admin einen neuen Screen aus dem Web-UI provisioniert - wie der Server Ansible-Playbooeke orchestriert - wie der Fortschritt angezeigt wird - Sicherheits- und Fehlerbehandlung Grundlagen zur Provisionierungs-Strategie finden sich in `docs/PROVISIONIERUNGSKONZEPT.md`. ## 1. Provisionierungs-Workflow im Admin-UI ### Seite: Admin → Screens → Neu ``` ┌──────────────────────────────────────────┐ │ Neuen Screen provisionieren │ ├──────────────────────────────────────────┤ │ │ │ Schritt 1 — Grunddaten │ │ │ │ Screen-ID / Slug * │ │ [ info10 ] │ │ (muss eindeutig sein, alphanumerisch) │ │ │ │ Anzeigename * │ │ [ Infowand Bottom-Left ________________ ] │ │ │ │ Beschreibung │ │ [ Neue Infowand Display, pos. 7______ ] │ │ │ │ Device Type * │ │ ⦿ Raspberry Pi 4 │ │ ○ Raspberry Pi 5 │ │ ○ x86 Linux Kiosk │ │ │ │ Aufloesung * │ │ [1920 x 1080 ] Standard fuer RPi │ │ │ │ Orientierung * │ │ ⦿ portrait (hochkant) │ │ ○ landscape (quer) │ │ │ │ Tenant-Zuordnung │ │ [ Dropdown: alle Tenants + "admin" ] │ │ │ │ [Weiter >] [Abbrechen] │ └──────────────────────────────────────────┘ ``` ### Schritt 2 — Netzwerk- und SSH-Einstellung ``` ┌──────────────────────────────────────────┐ │ Schritt 2 — Zugang zur Hardware │ │ │ │ Ziel-IP-Adresse * │ │ [ 192.168.1.50 ] │ │ │ │ SSH-Port │ │ [ 22 ] Standard │ │ │ │ Bootstrap-Benutzer * │ │ ⦿ root │ │ ○ pi │ │ ○ custom: [ ________________ ] │ │ │ │ Bootstrap-Authentifizierung * │ │ ⦿ Passwort (initial, wird durch Key │ │ ersetzt): │ │ [ Passwort ____________ ] │ │ ○ SSH-Key (nur wenn vorvorhanden): │ │ [ Datei auswaehlen ] oder │ │ [ PEM-Key einfuegen ] │ │ │ │ Test-Verbindung │ │ [SSH Test] [PING Test] │ │ │ │ [Weiter >] [Zurueck] [Abbrechen] │ └──────────────────────────────────────────┘ ``` ### Schritt 3 — Konfiguration und Optionen ``` ┌──────────────────────────────────────────┐ │ Schritt 3 — Konfiguration │ │ │ │ Fallback-Verzeichnis (lokal auf Player) │ │ [ /var/lib/signage/fallback ] │ │ │ │ Snapshot-Intervall (Sekunden) │ │ [ 300 ] 0 = deaktiviert │ │ │ │ MQTT-Broker-Adresse (Zielserver) │ │ [ mqtt.example.com ] auto-gefuellt │ │ │ │ Server-API-Adresse │ │ [ https://signage.example.com/api ] │ │ auto-gefuellt │ │ │ │ Gruppen-Zuordnung (optional) │ │ [ Checkboxen: wall-all, wall-row-1 ] │ │ │ │ Tags / Labels (optional) │ │ [ mainfloor, hightrafficarea ] │ │ │ │ [Weiter >] [Zurueck] [Abbrechen] │ └──────────────────────────────────────────┘ ``` ### Schritt 4 — Review und Start ``` ┌──────────────────────────────────────────┐ │ Schritt 4 — Uebersicht & Start │ │ │ │ Zusammenfassung: │ │ │ │ Screen: info10 │ │ Name: Infowand Bottom-Left │ │ Typ: Raspberry Pi 4 │ │ IP: 192.168.1.50 │ │ Aufloesung: 1920 x 1080 │ │ Orientierung: portrait │ │ Tenant: admin │ │ │ │ SSH-Verbindung wird hergestellt... │ │ [✓] SSH-Zugang verifiziert │ │ [✓] Pfadberechtigungen ok │ │ [✓] Speicherplatz ausreichend (15GB) │ │ │ │ Provisioning-Playbook: │ │ [ ] site.yml │ │ ├─ signage_base (Packages, Kernel) │ │ ├─ signage_display (X11, Chromium) │ │ ├─ signage_player (Agent, Config) │ │ └─ signage_provision (Setup-Jobs) │ │ │ │ Warnung: │ │ ! Diesen Prozess kann nicht unterbrochen│ │ werden. Typische Dauer: 10-15 Min. │ │ │ │ [Provisioning starten] [Abbrechen] │ └──────────────────────────────────────────┘ ``` ## 2. Provisioning-Job: Serverseitige Orchestrierung ### Architektur ``` ┌─────────────────────────────────────────┐ │ Admin-UI HTTP Request │ │ POST /api/v1/admin/provision │ └────────────┬────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Backend API (Go) │ │ - validiert Eingaben │ │ - erstellt ProvisioningJob in DB │ │ - queued Job in Job-Broker (Redis etc) │ └────────────┬────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Jobrunner Worker (Goroutine oder │ │ separater Go-Service) │ │ - laeuft im Server-Container │ │ - zeigt Fortschritt via Websocket │ └────────────┬────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Ansible Executor │ │ ansible-playbook site.yml │ │ -i inventory.ini │ │ -e vars.yml │ └────────────┬────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Target Device (Raspberry Pi) │ │ SSH: root@192.168.1.50 │ │ - installiert Packages │ │ - startet Services │ │ - synchonisiert Config │ └─────────────────────────────────────────┘ ``` ### Provisioning-Job-Modell ```sql CREATE TABLE provisioning_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), screen_id UUID NOT NULL REFERENCES screens(id), status TEXT NOT NULL CHECK (status IN ( 'pending', 'running', 'completed', 'failed' )), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, -- SSH/Ansible-Details target_ip TEXT NOT NULL, target_port INT NOT NULL DEFAULT 22, target_user TEXT NOT NULL, -- Verbrauch von Ressourcen ansible_job_id TEXT, -- Job-ID aus Ansible-Executor -- Fehlerbehandlung error_log TEXT, -- bei failure created_by_user_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ### Provisioning-Log-Modell ```sql CREATE TABLE provisioning_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), job_id UUID NOT NULL REFERENCES provisioning_jobs(id) ON DELETE CASCADE, line_number INT NOT NULL, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Quelle des Logs source TEXT NOT NULL CHECK (source IN ('ansible', 'agent', 'system')), level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')), -- Nachricht message TEXT NOT NULL, UNIQUE(job_id, line_number) ); ``` ## 3. Jobrunner-Implementierung ### Job-Verarbeitung (Pseudocode) ```go type ProvisioningJobRunner struct { db *sql.DB ansibleBinPath string logChannel chan ProvisioningLogMessage } func (r *ProvisioningJobRunner) ProcessJob(ctx context.Context, jobID uuid.UUID) error { // 1. Lade Job aus DB job := r.db.GetProvisioningJob(jobID) // 2. Setze Status auf "running" r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{ "status": "running", "started_at": time.Now(), }) // 3. Generiere Ansible-Inventar inventory := r.generateInventory(job) // [192.168.1.50] // ansible_user=root // ansible_password=*** // screen_id=info10 // ansible_become=yes // 4. Generiere vars.yml vars := r.generateVars(job) // screen_id: info10 // display_name: "Infowand Bottom-Left" // orientation: portrait // mqtt_broker: mqtt.example.com // etc. // 5. Fuehre Ansible aus cmd := exec.CommandContext(ctx, r.ansibleBinPath, "site.yml", "-i", inventoryPath, "-e", varsPath, "-v", // verbose ) // 6. Piping: Ansible-Ausgabe → Log-Dateien + Websocket stdout, _ := cmd.StdoutPipe() stderr, _ := cmd.StderrPipe() go r.streamLogs(job.ID, stdout, "ansible") go r.streamLogs(job.ID, stderr, "ansible") // 7. Warte auf Completion err := cmd.Run() // 8. Aktualisiere Job-Status if err != nil { r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{ "status": "failed", "completed_at": time.Now(), "error_log": err.Error(), }) return err } r.db.UpdateProvisioningJob(job.ID, map[string]interface{}{ "status": "completed", "completed_at": time.Now(), }) return nil } func (r *ProvisioningJobRunner) streamLogs(jobID uuid.UUID, reader io.Reader, source string) { scanner := bufio.NewScanner(reader) lineNum := 1 for scanner.Scan() { line := scanner.Text() // Persistiere in DB r.db.InsertProvisioningLog(ProvisioningLog{ JobID: jobID, LineNumber: lineNum, Source: source, Level: parseLogLevel(line), // heuristic Message: line, }) // Schreibe ins Websocket (siehe Abschnitt "Fortschritt") r.logChannel <- ProvisioningLogMessage{ JobID: jobID, Line: line, } lineNum++ } } ``` ### Ansible-Ausfuehrung mit Jumphost (optional) Falls der Server nicht direkt die Zielgeraete erreicht, kann ein Jumphost verwendet werden: ```yaml # ansible.cfg [defaults] inventory = inventory.ini host_key_checking = False retries = 3 [privilege_escalation] become = True become_method = sudo ``` ```ini # inventory.ini fuer Jumphost-Szenario [targets] 192.168.1.50 ansible_user=root ansible_password=*** \ ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p jumphost@example.com"' ``` ## 4. Fortschritt und Live-Updates ### Websocket-Kanal fuer Echtzeit-Logs **HTTP-Upgrade zu Websocket:** ``` GET /api/v1/admin/provision/{jobID}/logs Upgrade: websocket Connection: Upgrade ``` **Server sendet kontinuierlich:** ```json { "type": "log_line", "timestamp": "2025-03-25T14:22:00Z", "line": "TASK [signage_base : Update package cache] **", "source": "ansible", "level": "info" } ``` ```json { "type": "progress", "timestamp": "2025-03-25T14:22:15Z", "current_task": "signage_base : Update package cache", "task_number": 3, "total_tasks": 12, "percent": 25 } ``` ```json { "type": "status_change", "timestamp": "2025-03-25T14:35:00Z", "status": "completed", "duration_seconds": 780 } ``` ### UI-Anzeige waehrend Provisioning ``` ┌──────────────────────────────────────────┐ │ Provisioning laeuft: info10 │ │ Gestartet: vor 5 Min. │ │ Geschaetzte verbleibende Zeit: 8 Min. │ ├──────────────────────────────────────────┤ │ │ │ [████████████░░░░░░░░░░░░░░] 33% │ │ │ │ Aktuelle Aufgabe: │ │ ⊙ signage_base : Update package cache │ │ │ │ Letzte Logs: │ │ ├─ [14:22:00] TASK [signage_base ...] │ │ ├─ [14:22:05] ok: [192.168.1.50] │ │ ├─ [14:22:10] TASK [signage_display] │ │ ├─ [14:22:15] Chromium wird installiert│ │ └─ [14:22:20] ... │ │ │ │ [Auto-Refresh] [Pause] [Abbrechen] │ │ (Abbrechen: SSH-Verbindung wird nicht │ │ sofort getrennt, aber Job gestoppt) │ └──────────────────────────────────────────┘ ``` ## 5. Fehlerbehandlung und Recovery ### Fehlerszenarien | Fehler | Grund | Recovery | |---|---|---| | SSH-Verbindung fehlgeschlagen | IP falsch, Passwort falsch, Firewall | Logs zeigen SSH-Error, Admin kann Credentials korrigieren und neu starten | | Ansible-Playbook fehlgeschlagen | Paket-Versionskonflikt, Platz voll | Logs zeigen welcher Task fehlgeschlagen, Admin kann manuell SSH-en oder Job wiederholen | | Timeout nach 30 Min. | Sehr langsame Netzwerk oder Device haengt | Job wird abgebrochen, Admin kann Verbindung checken und neu starten | | Package-Download fehlgeschlagen | Mirror offline, Netzwerk unterbrochen | Ansible retry automatisch 3x, Logs zeigen wget-Error | ### Retry-Logik ``` Strategie: Exponentieller Backoff fuer Playbook-Fehler Fehler 1: Sofort wiederholen Fehler 2: Warte 5s, wiederhole Fehler 3: Warte 15s, wiederhole Fehler 4+: Gib auf, zeige Fehler ``` ### Admin-Recovery Falls ein Job fehlgeschlagen ist: ``` ┌──────────────────────────────────────────┐ │ Provisioning fehlgeschlagen: info10 │ │ │ │ Fehler: │ │ ssh: Could not resolve hostname │ │ (DNS-Fehler oder Geraet nicht erreichbar)│ │ │ │ Empfehlung: │ │ 1. IP-Adresse pruefen │ │ 2. Geraet von Hand SSH-en und testen │ │ 3. Job neu starten: [Neuer Versuch] │ │ │ │ Komplette Logs herunterladen: │ │ [logs-info10-20250325.txt] │ │ │ │ [Neuer Versuch] [Logs zeigen] [Zurueck]│ └──────────────────────────────────────────┘ ``` ## 6. Sicherheitsaspekte ### SSH-Key-Verwaltung **Phase 1 — Bootstrap mit Passwort:** ``` Admin gibt Passwort ein ↓ Server speichert Passwort NICHT ↓ Server uebergibt an Ansible nur waehrend dieser Session ↓ Ansible loggt sich ein, generiert SSH-Key ↓ SSH-Key wird auf dem Geraet als authorized_key eingetragen ↓ Passwort wird auf dem Geraet gelöscht oder deaktiviert ``` **Phase 2 — Dauerhaft mit SSH-Key:** ``` Server speichert SSH-Key in Secrets-Backend (z.B. HashiCorp Vault) Zukuenftige Ansible-Lauefe verwenden den Key ``` ### Ansible-Vault fuer sensitive Daten ```yaml # roles/signage_player/defaults/main.yml server_api_key: !vault | $ANSIBLE_VAULT;1.1;AES256 abcd1234... ``` Die Vault-Passphrase wird: - nie im Klartext gelagert - vom Server nur zur Laufzeit an Ansible uebergeben - in Logs nicht ausgegeben ### Sudo ohne Passwort Ansible erhoeht die Rechte per `sudo` ohne Passwort-Eingabe: ```sudoers # /etc/sudoers.d/ansible-signage ansible ALL=(ALL) NOPASSWD: ALL ``` (Alternativ: mit Passwort, das Ansible am Anfang einmal abfragt) ## 7. Verbindung zum bestehenden System ### Provisioning-Trigger aus Admin-UI ``` Admin-Seite: Screens → "+ Neuer Screen" ↓ Formular sammelt Grunddaten ↓ POST /api/v1/admin/provision ↓ Backend: 1. Screen in `screens` Tabelle eintragen 2. ProvisioningJob in `provisioning_jobs` anlegen 3. Job in Broker queuen ↓ Jobrunner: 1. Holt Job aus Broker 2. Startet Ansible 3. Streamt Logs via Websocket 4. Aktualisiert Job-Status bei Completion ↓ Admin sieht Live-Updates im UI ``` ### Nach erfolgreichem Provisioning ``` Job-Status: "completed" ↓ Agent auf dem Display startet ↓ Agent registriert sich beim Server ↓ Server setzt Screen-Status auf "online" ↓ Admin sieht Screen in Tabelle mit Status "online" ↓ Admin kann sofort Kampagnen/Playlists zuweisen ``` ## 8. Konfigurierbare Parameter In `/etc/signage/provision.yml`: ```yaml jobrunner: max_concurrent_jobs: 3 ansible_timeout_sec: 1800 playbook_path: "/srv/ansible/site.yml" inventory_template_path: "/srv/ansible/inventory.ini.tpl" vars_template_path: "/srv/ansible/vars.yml.tpl" ssh: known_hosts_file: "/etc/signage/.ssh/known_hosts" key_storage: "vault" # oder "filesystem" ansible: verbosity: "-vv" # oder "-v", "-vvv" extra_args: "" ``` ## 9. Zusammenfassung Der Jobrunner: - **ist web-gesteuert** — Provisioning-UI mit Multi-Step-Wizard - **ist automatisiert** — Ansible Playbooks, nicht manuelle SSH-Kommandos - **ist transparent** — Live-Logs und Fortschritt-Anzeige - **ist sicher** — SSH-Keys, Ansible-Vault, keine Plaintext-Credentials in Logs - **ist resilient** — Retry-Logik und Error-Recovery - **ist erweiterbar** — neue Rollen und Tasks koennen hinzugefuegt werden ohne UI-Aenderung