# 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
