diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9b55d2e..2d81b07 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -62,7 +62,10 @@ jobs: node-version: "24" package-manager-cache: false - name: Check JavaScript syntax - run: node --check provisioner/static/app.js + run: | + for script in provisioner/static/*.js; do + node --check "$script" + done container-policy: runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }} diff --git a/README.md b/README.md index 62799c0..314108c 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,20 @@ Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deploy ## Erster Installationslauf +Die [Anleitung zur Weboberfläche](docs/web-interface.md) führt durch die +grafischen Formulare. Profile, Host-Einstellungen, Modulparameter und +Schrittfolgen benötigen keine JSON-Eingabe. Für eigene Skripte stehen +Vorlagen und Dateiupload zur Verfügung. + 1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`, `author`, `admin` und `developer`. 2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten Token sicher speichern. Er ist Bestandteil des Installationsmediums. 3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen. Das Medium erst nach bestandenem Labortest freigeben. -4. Root-Zugang als Geheimnis speichern, Installationsprofil und - Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und +4. Root-Zugang als Geheimnis speichern; ein eingegebenes Root-Passwort wird + automatisch in den Installer-Hash umgewandelt. Installationsprofil und + Postinstallationsprofil über die Formulare anlegen. Module enthalten `check`, `apply` und `verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig muss eine andere Person als der Autor veröffentlichen. 5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen @@ -149,6 +155,18 @@ Unter PowerShell entsprechend `.\.venv\Scripts\python.exe -m pytest` und `.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden. Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen. +Die optionalen Browserprüfungen testen die grafischen Formulare mit Chromium +und einer eigenen lokalen Testdatenbank: + +```bash +uv pip install --python .venv/bin/python -e ".[dev,browser]" +.venv/bin/python -m playwright install chromium +.venv/bin/python tests/browser_forms.py +``` + +Unter Windows `.venv/Scripts/python.exe` als Python-Pfad verwenden. +Screenshots und Testdaten liegen im ignorierten Verzeichnis `.cache/ui-browser/`. + Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich diff --git a/docs/api-workflow.md b/docs/api-workflow.md index cd9a149..f388246 100644 --- a/docs/api-workflow.md +++ b/docs/api-workflow.md @@ -48,8 +48,9 @@ stattdessen ohne Seriennummernfilter und Gerätenamen angegeben werden: } ``` -In der Weboberfläche trägt **ZFS (RAID0) für Einzelplatte** diese Einstellung -im Profilformular ein. Die übrigen Profilparameter werden dabei beibehalten. +In der Weboberfläche werden dafür **ZFS**, **RAID0 / Einzelplatte** und die +automatische Plattenwahl im Profilformular ausgewählt. JSON-Eingaben sind dort +nicht erforderlich; die übrigen Profilparameter besitzen eigene Formularfelder. `selection: "all"` verwendet alle vom Proxmox-Installer erkannten Zielplatten; es prüft nicht, ob tatsächlich nur eine Platte vorhanden ist. Die native Antwort nutzt dafür `filter.DEVTYPE = "disk"`. CD-ROM- und ISO9660-Installationsmedien diff --git a/docs/openapi.json b/docs/openapi.json index d437e42..6168a55 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Proxmox AIS", "description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.", - "version": "0.9.2" + "version": "0.9.3" }, "paths": { "/health/live": { @@ -2371,6 +2371,15 @@ "minLength": 1, "title": "Name" }, + "kind": { + "type": "string", + "enum": [ + "value", + "root_password" + ], + "title": "Kind", + "default": "value" + }, "value": { "type": "string", "maxLength": 16384, diff --git a/docs/web-interface.md b/docs/web-interface.md new file mode 100644 index 0000000..a03a70e --- /dev/null +++ b/docs/web-interface.md @@ -0,0 +1,60 @@ +# Bedienung über die Weboberfläche + +Profile, Server, Schritte und Modulparameter werden über Formularfelder, +Auswahllisten sowie Schaltflächen zum Hinzufügen, Entfernen und Sortieren +bearbeitet. Die Anwendung erzeugt die API-Konfiguration intern. + +## Root-Zugang anlegen + +Unter **Einstellungen → Geheimnisse** einen Zugang mit einer Bezeichnung +anlegen. Als Verwendung **Root-Passwort für die Proxmox-Installation** auswählen +und ein Passwort mit mindestens zwölf Zeichen eingeben. Das Tool erzeugt +den SHA512-crypt-Hash und speichert ihn verschlüsselt. Das eingegebene Passwort +und der Hash werden nicht erneut angezeigt. Andere Geheimnisse oder vorhandene +Hashes lassen sich über die zweite Verwendung unverändert hinterlegen. + +## Installationsprofil + +Unter **Installationsprofile → Profil erstellen** den Namen und die geprüften +Zielbuilds eintragen. Weitere Builds werden mit **Eintrag hinzufügen** ergänzt. +Sprache, Zeitzone, E-Mail, Netzwerk und Datenträger stehen in eigenen Bereichen. +Den Root-Zugang nach seinem Namen auswählen; es muss keine Referenz-ID kopiert werden. +FQDN und Management-IP können später vom zugeordneten Server stammen. + +Für Server mit immer genau einer Zielplatte **ZFS**, **RAID0 / Einzelplatte** +und die automatische Plattenwahl verwenden. Dabei werden alle vom Installer +erkannten Zielplatten verwendet; die Anwendung zählt die tatsächlich eingebauten +Platten nicht. Für eine ausdrückliche Auswahl stehen Seriennummern und WWNs zur +Verfügung. Erweiterte ZFS- und LVM-Optionen besitzen Zahlen- und Auswahlfelder. + +Eine neue Version übernimmt die vorhandenen Werte. Leere optionale Felder +entfernen die jeweilige Vorgabe. **Entwurf speichern** speichert das Profil; +die Veröffentlichung mit Testnachweis bleibt ein eigener Schritt. + +## Module und Nachinstallation + +Ein Modul aus dem Vorlagenkatalog übernehmen oder im Modulformular eine Vorlage +auswählen. Eigene vorhandene Skripte lassen sich als Datei hochladen; die +Skriptansicht ist schreibgeschützt. Parameter werden als Felder mit Typ, +Beschreibung, Pflichtstatus, Vorgaben und Grenzen angelegt. + +Im Postinstallationsprofil einen Schritt hinzufügen und eine veröffentlichte +Modulversion nach Namen auswählen. Die Parameter erscheinen als passende +Eingaben, Listen, Checkboxen und Auswahllisten. Benötigte Geheimnisse werden +ebenfalls nach Namen zugeordnet. Schritte können entfernt oder mit den +Pfeiltasten umgeordnet werden. Die bestehenden Schrittkennungen bleiben dabei +erhalten. Mindestens ein verpflichtender Prüfschritt ist erforderlich. + +## Server erfassen + +Hostname und Standort eintragen, Hardware-Kennungen über getrennte Typ- und +Wertfelder ergänzen und Profile sowie Medium auswählen. Für mehrere Server +**Mehrere Server erfassen** verwenden: Jede Karte enthält einen Server; +Standort und Profilzuordnungen gelten gemeinsam. + +Abweichende Einstellungen eines Hosts stehen in einem aufklappbaren Bereich. +Nicht aktivierte Bereiche übernehmen die Profilwerte. Vor einer Installation +die aufgelöste Vorschau prüfen und die Freigabe erteilen. + +Die Erstellung des eigentlichen ISO-Mediums erfolgt weiterhin über den offiziellen +Assistant auf einer Build-Maschine; siehe [ISO-Vorbereitung](iso-preparation.md). diff --git a/provisioner/__init__.py b/provisioner/__init__.py index 69474f2..b293ff7 100644 --- a/provisioner/__init__.py +++ b/provisioner/__init__.py @@ -1,3 +1,3 @@ """Proxmox AIS: controlled installation and post-installation provisioning.""" -__version__ = "0.9.2" +__version__ = "0.9.3" diff --git a/provisioner/app.py b/provisioner/app.py index 8c70c1a..636c3bb 100644 --- a/provisioner/app.py +++ b/provisioner/app.py @@ -390,15 +390,16 @@ def create_app(settings: Settings | None = None): return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection) @app.get("/api/v1/secrets") - def secrets_list(user=Depends(roles())): + def secrets_list(user=Depends(roles("author","operator"))): with db.connection() as connection: return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")] @app.post("/api/v1/secrets",status_code=201) def create_secret(payload:SecretCreate,user=Depends(roles())): + value = security.hash_installer_password(payload.value) if payload.kind == "root_password" else payload.value with db.connection(write=True) as connection: secret_id = new_id("secret") - connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso())) + connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(value),now_iso())) audit(connection,user["id"],"secret.created",secret_id) return {"id":secret_id,"name":payload.name} diff --git a/provisioner/models.py b/provisioner/models.py index ffe2f82..091d84e 100644 --- a/provisioner/models.py +++ b/provisioner/models.py @@ -1,9 +1,9 @@ -from typing import Any, Literal +from typing import Annotated, Any, Literal from ipaddress import ip_interface import re import uuid -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationInfo, field_validator class Model(BaseModel): @@ -156,7 +156,19 @@ class UserCreate(Model): class SecretCreate(Model): name: str = Field(min_length=1, max_length=120) - value: str = Field(min_length=1, max_length=16384) + kind: Literal["value", "root_password"] = "value" + value: Annotated[str, StringConstraints(strip_whitespace=False)] = Field(min_length=1, max_length=16384) + + @field_validator("value", mode="before") + @classmethod + def secret_value(cls, value, info: ValidationInfo): + if not isinstance(value, str): + return value + if info.data.get("kind") == "root_password": + if not 12 <= len(value) <= 1024 or "\x00" in value: + raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.") + return value + return value.strip() class Enroll(Model): diff --git a/provisioner/security.py b/provisioner/security.py index 6bec862..3cb07ff 100644 --- a/provisioner/security.py +++ b/provisioner/security.py @@ -8,6 +8,13 @@ import re import secrets from cryptography.fernet import Fernet +from passlib.hash import sha512_crypt + + +# The installer requires a Linux crypt hash. The builtin backend also works +# on Python 3.13+, where the standard-library crypt module no longer exists. +_installer_password_hash = sha512_crypt.using(rounds=656000) +_installer_password_hash.set_backend("builtin") def canonical(value): @@ -43,6 +50,12 @@ class Security: result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1) return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode() + @staticmethod + def hash_installer_password(password): + if not 12 <= len(password) <= 1024 or "\x00" in password: + raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.") + return _installer_password_hash.hash(password) + @staticmethod def verify_password(password, stored): try: diff --git a/provisioner/static/app.js b/provisioner/static/app.js index 4264aa3..3c24220 100644 --- a/provisioner/static/app.js +++ b/provisioner/static/app.js @@ -128,12 +128,12 @@ function dashboard(data) { } function hostsPage(hosts, discoveries=[]) { const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase())))); - return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`
${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}
`+(unassigned.length?`

Entdeckte Hardware ${unassigned.length}

Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.

${badge('discovered')}
${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`${arr(d.identities).map(i=>`
${esc(i.kind)}: ${esc(i.value)}
`).join('')}${esc(d.site)}
${esc(d.build)}${esc(d.reason)}${canOperate()?``:''}`))}
`:''); + return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Mehrere Server erfassen','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`
${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}
`+(unassigned.length?`

Entdeckte Hardware ${unassigned.length}

Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.

${badge('discovered')}
${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`${arr(d.identities).map(i=>`
${esc(i.kind)}: ${esc(i.value)}
`).join('')}${esc(d.site)}
${esc(d.build)}${esc(d.reason)}${canOperate()?``:''}`))}
`:''); } function hostPage(host) { const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities); return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`${svg('back')}Inventar${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+ - `

Serverkonfiguration

${badge(host.blocked?'blocked':host.status)}
FQDN
${esc(host.fqdn||'–')}
Management-IP
${esc(host.management_ip||'–')}
Standort
${esc(host.site||'–')}
Tags
${(host.tags||[]).map(t=>`${esc(t)}`).join('')||'–'}
Letzter Kontakt
${esc(fmtDate(host.last_contact||host.last_seen))}
Versionsstand
${esc(host.version)}
${identities.length?`
${identities.map(i=>`
${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}
${esc(i.value)}
`).join('')}
`:'

Noch keine Identitäten hinterlegt.

'}

Installationshistorie

${runs.length}
${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}

Profilzuordnung

${svg('layers')}
Installation
${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}
Postinstallation
${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}
ISO-Medium
${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}

Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.

${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}
${host.discovered_data?`

Erkannte Systemdaten

${esc(json(host.discovered_data))}
`:''}

Hostüberschreibungen

${esc(json(host.overrides))}
`; + `

Serverkonfiguration

${badge(host.blocked?'blocked':host.status)}
FQDN
${esc(host.fqdn||'–')}
Management-IP
${esc(host.management_ip||'–')}
Standort
${esc(host.site||'–')}
Tags
${(host.tags||[]).map(t=>`${esc(t)}`).join('')||'–'}
Letzter Kontakt
${esc(fmtDate(host.last_contact||host.last_seen))}
Versionsstand
${esc(host.version)}
${identities.length?`
${identities.map(i=>`
${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}
${esc(i.value)}
`).join('')}
`:'

Noch keine Identitäten hinterlegt.

'}

Installationshistorie

${runs.length}
${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}

Profilzuordnung

${svg('layers')}
Installation
${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}
Postinstallation
${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}
ISO-Medium
${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}

Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.

${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}
${host.discovered_data?`

Erkannte Systemdaten

${dataView(host.discovered_data)}
`:''}

Hostüberschreibungen

${dataView(host.overrides)}
`; } function profilesPage(profiles, kind) { const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind); @@ -151,10 +151,10 @@ function runPage(run) { const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested; const actions=`${svg('back')}Alle Läufe${canOperate()&&resumeAllowed?actionButton('Wiederaufnehmen','resume-run','play',`data-id="${esc(run.id)}"`,true):''}${canOperate()&&!terminal?actionButton('Abbrechen','cancel-run','stop',`data-id="${esc(run.id)}"`)+actionButton('Lauf abgleichen','reconcile-run','shield',`data-id="${esc(run.id)}"`):''}`; let content=''; - if(state.runTab==='steps') content=steps.length?`
    ${steps.map((s,i)=>`
  1. ${s.status==='succeeded'?'✓':i+1}
    ${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}

    Modul ${esc(s.module_name || s.module_id || '–')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}

    ${s.required===false?'

    Optionaler Schritt

    ':''}${s.error?`

    ${esc(s.error)}

    `:''}${s.verification&&Object.keys(s.verification).length?`
    Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}
    ${esc(json(s.verification))}
    `:''}${s.checkpoint?`
    Checkpoint
    ${esc(json(s.checkpoint))}
    `:''}
    ${badge(s.status)}
  2. `).join('')}
`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow'); + if(state.runTab==='steps') content=steps.length?`
    ${steps.map((s,i)=>`
  1. ${s.status==='succeeded'?'✓':i+1}
    ${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}

    Modul ${esc(s.module_name || s.module_id || '–')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}

    ${s.required===false?'

    Optionaler Schritt

    ':''}${s.error?`

    ${esc(s.error)}

    `:''}${s.verification&&Object.keys(s.verification).length?`
    Prüfergebnis${s.status==='failed'?' / Fehlerursache':''}${dataView(s.verification)}
    `:''}${s.checkpoint?`
    Checkpoint${dataView(s.checkpoint)}
    `:''}
    ${badge(s.status)}
  2. `).join('')}
`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow'); if(state.runTab==='events') content=eventList(events,'Noch keine Laufereignisse'); if(state.runTab==='logs') content=logs.length?`
${esc(logs.map(l=>typeof l==='string'?l:`${l.created_at?fmtDate(l.created_at)+' ':''}${l.step_id?'['+l.step_id+'] ':''}${l.content||l.text||l.message||json(l)}`).join('\n'))}
`:empty('Noch keine Protokolldaten','Der Runner übermittelt redigierte Protokolle während der Ausführung.','code'); - return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`

Ausführungsstatus

${badge(run.status)}
${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>``).join('')}
${run.error||run.error_reason?`
${esc(run.error||run.error_reason)}
`:''}
${content}

Laufdaten

Server
${esc(run.host_fqdn||shortId(run.host_id))} ↗
Gestartet
${esc(fmtDate(run.started_at||run.created_at))}
Abgeschlossen
${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):'–'}
Letzter Heartbeat
${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}
Versionsstand
${esc(run.version)}
Manifest-Digest
${esc(run.manifest_digest||'Noch nicht erstellt')}
Antwort-Digest
${esc(run.answer_digest||run.answer_sha256||'–')}

Fixierte Konfiguration

${svg('lock')}

Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.

${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}
`; + return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`

Ausführungsstatus

${badge(run.status)}
${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>``).join('')}
${run.error||run.error_reason?`
${esc(run.error||run.error_reason)}
`:''}
${content}

Laufdaten

Server
${esc(run.host_fqdn||shortId(run.host_id))} ↗
Gestartet
${esc(fmtDate(run.started_at||run.created_at))}
Abgeschlossen
${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):'–'}
Letzter Heartbeat
${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}
Versionsstand
${esc(run.version)}
Manifest-Digest
${esc(run.manifest_digest||'Noch nicht erstellt')}
Antwort-Digest
${esc(run.answer_digest||run.answer_sha256||'–')}

Fixierte Konfiguration

${svg('lock')}

Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.

${dataView(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id})}
`; } function mediaPage(records, groups=[]) { return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+ @@ -180,7 +180,7 @@ function closeModal() {modal.close();modalSubmit=null;document.getElementById('m function field(name,label,value='',options={}) { const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`; let input; - if(options.type==='textarea'||options.type==='json') input=``; + if(options.type==='textarea') input=``; else if(options.type==='select') input=``; else if(options.type==='checkbox') return ``; else input=``; @@ -189,24 +189,10 @@ function field(name,label,value='',options={}) { function form(fields,label='Speichern',intro='') { return `${intro}`; } -function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}} const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean); const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))]; -async function hostForm(existing=null, discovery=null) { - const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]); - const h=existing||discovery||{}; - const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'}); - showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{ - const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};}); - const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')}; - if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;} - await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh(); - },'INVENTAR'); -} -function hostImportForm() { - const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}]; - showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT'); -} +async function hostForm(existing=null, discovery=null) { return graphicalHostForm(existing,discovery); } +async function hostImportForm() { return graphicalHostImport(); } async function moduleCatalog() { const catalog=arr(await api('/modules/builtin')); showModal('Basismodul als Entwurf übernehmen',`
Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.
${catalog.map(m=>`
${svg('code')}
${esc(m.name)}

${esc(m.description)}

`).join('')}
`,null,'MODULVORLAGEN'); @@ -215,7 +201,7 @@ async function moduleCatalog() { function previewContent(preview) { const p=preview||{}, warnings=arr(p.warnings); const network=p.resolved?.network||{}; - return `${warnings.length?`
${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('
')}
`:''}
Aufgelöste Konfiguration & Herkunft
${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}
`; + return `${warnings.length?`
${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('
')}
`:''}
Aufgelöste Konfiguration & Herkunft${dataView({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps})}
`; } async function approveHost(id, previewOnly=false) { const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]); @@ -228,30 +214,8 @@ async function approveHost(id, previewOnly=false) { closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh(); },'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE'); } -const installationExample = { - global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'}, - network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}}, - disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'}, - root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES' -}; -async function profileForm(kind, existing=null) { - const p=existing||{}, install=kind==='installation'; - let info=install?`
Server mit einer Festplatte

Die Vorlage verwendet alle vom Installer erkannten Zielplatten. Für Server mit genau einer Platte sind damit weder Seriennummer noch Gerätename nötig.

${actionButton('ZFS (RAID0) für Einzelplatte','use-single-disk-zfs','disc')}
`:''; - if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`
Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: ${esc(m.id)}`).join('
'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}
`;} - const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'}); - showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{ - await api('/profiles',{method:'POST',body:{name:data.get('name'),kind,target_builds:split(data.get('target_builds')),values:parseJSON(data,'values'),steps:install?[]:parseJSON(data,'steps',[]),reason:data.get('reason')||'',...(!install?{reboot_budget:Number(data.get('reboot_budget'))}:{})}}); - closeModal();toast('Profilentwurf gespeichert. Eine Veröffentlichung benötigt einen Testnachweis.');await refresh(); - },install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL'); -} -const moduleExample = '#!/usr/bin/env bash\nset -euo pipefail\n\ncheck() {\n systemctl is-active --quiet pveproxy\n}\n\napply() {\n # Nur erforderliche, geprüfte Änderungen ausführen.\n return 0\n}\n\nverify() {\n systemctl is-active --quiet pveproxy\n}\n\ncase "${1:-}" in\n check) check ;;\n apply) apply ;;\n verify) verify ;;\n *) echo "Usage: $0 {check|apply|verify}" >&2; exit 2 ;;\nesac\n'; -function moduleForm(existing=null) { - const m=existing||{}; - const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(m.target_builds).join(', '),{required:true,placeholder:'z. B. 9.1-1'})+field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds||300,{type:'number',required:true,min:1,max:7200})+field('source','Bash-Quelltext',m.source||moduleExample,{type:'json',required:true,full:true,rows:17,hint:'Aufruf: bash modul.sh check|apply|verify parameter.json. Parameter werden als JSON-Datei übergeben.'})+field('parameters_schema','Parameterschema (JSON Schema)',m.parameters_schema||{type:'object',properties:{},additionalProperties:false},{type:'json',full:true,rows:6})+field('dependencies','Abhängige Modulnamen',arr(m.dependencies).join(', '),{full:true,placeholder:'Optional: exakte Modulnamen, durch Komma getrennt'})+field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe||false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit im Test nachgewiesen wurde.'})+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Modulstand'}); - showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{ - await api('/modules',{method:'POST',body:{name:data.get('name'),source:data.get('source'),parameters_schema:parseJSON(data,'parameters_schema'),dependencies:split(data.get('dependencies')),target_builds:split(data.get('target_builds')),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh(); - },'VERSIONIERTE SKRIPTMODULE'); -} +async function profileForm(kind, existing=null) { return graphicalProfileForm(kind,existing); } +async function moduleForm(existing=null) { return graphicalModuleForm(existing); } async function publishObject(type, id) { const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`); const intro=`
${esc(item.name)} · Version ${esc(item.version)}
Veröffentlichten Inhalt können Sie nicht mehr ändern. Dokumentieren Sie den praktischen Test auf einem passenden Testhost. Im Vieraugenmodus muss eine andere Person den Entwurf veröffentlichen.
`; @@ -260,10 +224,10 @@ async function publishObject(type, id) { },'VERÖFFENTLICHUNG'); } function inspectProfile(p) { - showModal(p.name,`
${esc(json({values:p.values,steps:p.steps}))}
${canAuthor()?``:''}
`,null,'PROFILDETAILS'); + showModal(p.name,`${dataView({values:p.values,steps:p.steps})}
${canAuthor()?``:''}
`,null,'PROFILDETAILS'); } function inspectModule(m) { - showModal(m.name,`${m.source?`
${esc(m.source)}
`:'
Der Quelltext ist für Skriptautoren und Administratoren sichtbar.
'}
Parameterschema & Abhängigkeiten
${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}
`,null,'MODULDETAILS'); + showModal(m.name,`${m.source?`
${esc(m.source)}
`:'
Der Quelltext ist für Skriptautoren und Administratoren sichtbar.
'}
Parameterschema & Abhängigkeiten${dataView({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds})}
`,null,'MODULDETAILS'); } async function isoForm() { const groups=arr(await api('/groups')); @@ -294,9 +258,10 @@ function userForm() { },'BENUTZER & ROLLEN'); } function secretForm() { - showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','
Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.
'),async data=>{ - const result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh(); - if(result?.id)showModal('Geheimnis gespeichert',`

Referenz für root_secret_id oder einen Schritt:

`); + const fields=field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Zugang · PVE Berlin'})+field('kind','Verwendung','root_password',{type:'select',full:true,options:[{value:'root_password',label:'Root-Passwort für die Proxmox-Installation'},{value:'value',label:'Anderes Geheimnis oder vorhandener Passwort-Hash'}]})+field('value','Passwort / Geheimnis','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für ein neues Root-Passwort mindestens 12 Zeichen eingeben. Das Tool bereitet es für den Installer auf.'}); + showModal('Zugang hinterlegen',form(fields,'Verschlüsselt speichern','
Den gespeicherten Zugang anschließend im Profil oder Schritt nach seinem Namen auswählen.
'),async data=>{ + if(data.get('kind')==='root_password'&&String(data.get('value')).length<12)throw new Error('Das Root-Passwort benötigt mindestens 12 Zeichen.'); + await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value'),kind:data.get('kind')}});closeModal();toast('Zugang gespeichert und in den Auswahllisten verfügbar.');state.settingsTab='secrets';await refresh(); },'BETRIEBSGEHEIMNIS'); } async function runAction(id, action) { @@ -359,7 +324,7 @@ async function handleAction(button) { if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;} if(action==='refresh'){await refresh();return;} if(action==='create-host'){await hostForm();return;} - if(action==='import-hosts'){hostImportForm();return;} + if(action==='import-hosts'){await hostImportForm();return;} if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;} if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;} if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;} @@ -368,22 +333,13 @@ async function handleAction(button) { await api(`/hosts/${encodeURIComponent(id)}`,{method:'PATCH',body:{expected_version:host.version,blocked:!host.blocked}});toast(host.blocked?'Host entsperrt.':'Host gesperrt.');await refresh();return; } if(action==='create-profile'){await profileForm(button.dataset.kind);return;} - if(action==='use-single-disk-zfs'){ - const input=modal.querySelector('textarea[name="values"]'); - const values=JSON.parse(input.value); - if(!values || typeof values!=='object' || Array.isArray(values))throw new Error('Installationskonfiguration muss ein JSON-Objekt sein.'); - values.disk_setup={filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}}; - input.value=json(values); - toast('ZFS (RAID0) mit automatischer Plattenwahl eingetragen.'); - return; - } if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;} if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;} if(action==='publish-profile'){await publishObject('profiles',id);return;} - if(action==='create-module'){moduleForm();return;} + if(action==='create-module'){await moduleForm();return;} if(action==='module-catalog'){await moduleCatalog();return;} - if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;} - if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;} + if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);await moduleForm({...selected,version:0});return;} + if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')await moduleForm(m);else inspectModule(m);return;} if(action==='publish-module'){await publishObject('modules',id);return;} if(action==='create-iso'){await isoForm();return;} if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;} @@ -398,7 +354,7 @@ async function handleAction(button) { const el=document.getElementById('copy-value'); try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return; } - if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`
${esc(json(e))}
`,null,e?.action||'AUDIT');} + if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`${dataView(e)}
`,null,e?.action||'AUDIT');} } document.addEventListener('click',async event=>{ const button=event.target.closest('[data-action]'); diff --git a/provisioner/static/form-controls.js b/provisioner/static/form-controls.js new file mode 100644 index 0000000..d3cbbae --- /dev/null +++ b/provisioner/static/form-controls.js @@ -0,0 +1,132 @@ +/* Graphical editors for typed settings; serialization stays inside the client. */ +'use strict'; + +const editorTypes = {string:'Text',integer:'Ganze Zahl',number:'Zahl',boolean:'Ja / Nein',object:'Gruppe',array:'Liste',null:'Nicht gesetzt'}; +const handledEditorEvents = new WeakSet(); +const editorLabels = {global:'Allgemein',network:'Netzwerk',disk_setup:'Systemdatenträger',filesystem:'Dateisystem',selection:'Plattenauswahl',zfs:'ZFS',raid:'RAID-Modus',filter:'Geräteauswahl',filter_match:'Verknüpfung',expected_count:'Erwartete Plattenanzahl',expected_serials:'Bestätigte Plattenkennungen',inventory_evidence:'Inventarnachweis',root_secret_id:'Root-Zugang',keyboard:'Tastatur',country:'Land',timezone:'Zeitzone',mailto:'E-Mail',fqdn:'Hostname',cidr:'IP-Adresse mit Präfix',gateway:'Gateway',dns:'DNS-Server',source:'Quelle',values:'Einstellungen',steps:'Schritte',parameters:'Parameter',secret_refs:'Geheimnisse',required:'Erforderlich',name:'Name',version:'Version',status:'Status',id:'Kennung',properties:'Felder',items:'Listenelemente',type:'Typ',description:'Beschreibung',default:'Standardwert',enum:'Auswahlwerte',additionalProperties:'Weitere Felder erlauben',minimum:'Minimum',maximum:'Maximum',minLength:'Mindestlänge',maxLength:'Maximallänge',minItems:'Mindesteinträge',maxItems:'Maximale Einträge',uniqueItems:'Eindeutige Einträge',pattern:'Formatregel'}; +const editorLabel = key => editorLabels[key] || String(key).replace(/[_-]/g,' '); +Object.assign(editorLabels,{allowed_versions:'Erlaubte PVE-Versionen',dns_names:'Zu prüfende DNS-Namen',minimum_free_mb:'Freier Speicher in MiB',url:'Adresse',suite:'Distribution',components:'Komponenten',keyring:'Signaturschlüssel',packages:'Pakete',users:'Benutzer',authorized_keys:'Öffentliche SSH-Schlüssel',servers:'Zeitserver',enabled:'Aktiviert',path:'Pfad',content:'Inhalte',storage_ids:'Storage-Namen',require_time_sync:'Zeitsynchronisation erforderlich',resolved:'Wirksame Einstellungen',provenance:'Herkunft der Einstellungen',profiles:'Profile',reboot_budget:'Erlaubte Neustarts',timeout_seconds:'Zeitlimit in Sekunden',retry_safe:'Wiederholung erlaubt'}); +const editorType = value => value===null?'null':Array.isArray(value)?'array':typeof value==='number'?(Number.isInteger(value)?'integer':'number'):typeof value; +const editorDefault = (type,schema={}) => Object.hasOwn(schema,'default')?structuredClone(schema.default):schema.enum?.length?structuredClone(schema.enum[0]):({object:{},array:[],boolean:false,integer:0,number:0,null:null,string:''}[type]??''); +function editorSchemaType(schema,value) { + const declared=Array.isArray(schema.type)?schema.type:[schema.type]; + const actual=editorType(value); + return declared.includes(actual)?actual:declared.includes('number')&&actual==='integer'?'number':declared.find(type=>Object.hasOwn(editorTypes,type))||actual; +} +function dataEditor(name,value={},options={}) { + return `
${dataNode(value,options.schema||{},options.label||editorLabel(name))}
`; +} +function dataNode(value,schema={},label='Wert') { + schema=schema && typeof schema==='object'?schema:{}; + const type=editorSchemaType(schema,value); + const declared=Array.isArray(schema.type)?schema.type:[schema.type]; + const fixed=declared.filter(Boolean).length===1 || Array.isArray(schema.enum) || Object.hasOwn(schema,'const'); + const choice=fixed?`${esc(editorTypes[type]||type)}`:``; + return `
${esc(label)}${choice}
${schema.description?`

${esc(schema.description)}

`:''}
${dataNodeContent(value,type,schema,label)}
`; +} +function dataNodeContent(value,type,schema,label) { + if(Object.hasOwn(schema,'const'))return `

Fest vorgegeben: ${esc(String(schema.const))}

`; + if(Array.isArray(schema.enum)) { + const choices=[...schema.enum]; + if(value!==undefined&&!choices.some(item=>json(item)===json(value)))choices.push(value); + return ``; + } + if(type==='object'||type==='array') { + const object=type==='object'; + const properties=schema.properties||{}; + const entries=object?Object.entries(value&&typeof value==='object'&&!Array.isArray(value)?value:{}):Array.isArray(value)?value.map((item,index)=>[String(index),item]):[]; + if(object)for(const key of schema.required||[])if(!entries.some(([name])=>name===key))entries.push([key,editorDefault(editorSchemaType(properties[key]||{},''),properties[key]||{})]); + const children=entries.map(([key,item],index)=>dataEntry(key,item,object,object?properties[key]||{}:schema.items||{},index,(schema.required||[]).includes(key),object&&Object.hasOwn(properties,key))).join(''); + const allowed=object?Object.keys(properties):[]; + const picker=object&&allowed.length?``:''; + const addAllowed=!object||schema.additionalProperties!==false||allowed.length; + return `
${children}
${addAllowed?`
${picker}
`:''}`; + } + if(type==='boolean')return ``; + if(type==='null')return '

Kein Wert gesetzt.

'; + if(type==='number'||type==='integer')return ``; + return ``; +} +function dataEntry(key,value,object,schema,index,required=false,known=false) { + const title=schema.title|| (object?editorLabel(key):`Eintrag ${index+1}`); + return `
${object?``:''}
${dataNode(value,schema,title)}
${!object?'':''}${!required?'':'Pflichtfeld'}
`; +} +function readDataEditor(container,name) { + const editor=[...(container.matches?.('[data-editor]')?[container]:[]),...container.querySelectorAll('[data-editor]')].find(item=>item.dataset.editor===name); + if(!editor)throw new Error(`Das Eingabefeld „${editorLabel(name)}“ ist nicht verfügbar.`); + return readDataNode(editor.querySelector('[data-value-node]')); +} +function readDataNode(node) { + const type=node.dataset.valueKind; + const schema=JSON.parse(node.dataset.valueSchema||'{}'); + if(Object.hasOwn(schema,'const'))return structuredClone(schema.const); + const content=node.querySelector(':scope > .data-node-content'); + const enumInput=content.querySelector(':scope > label > [data-enum-value]'); + if(enumInput)return JSON.parse(enumInput.value); + if(type==='object'||type==='array') { + const entries=[...content.querySelector(':scope > .data-children').children]; + if(type==='array')return entries.map(entry=>readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]'))); + const result={}; + for(const entry of entries) { + const key=entry.querySelector(':scope > .data-key-label > [data-entry-key]').value.trim(); + if(!key)throw new Error('Bitte jedem Feld einen Namen geben.'); + if(Object.hasOwn(result,key))throw new Error(`Das Feld „${key}“ ist doppelt vorhanden.`); + Object.defineProperty(result,key,{value:readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]')),enumerable:true,writable:true,configurable:true}); + } + return result; + } + if(type==='null')return null; + const input=content.querySelector('[data-scalar-value]'); + if(type==='boolean')return input.checked; + if(type==='number'||type==='integer') { + const number=Number(input.value); + if(!input.value.trim()||!Number.isFinite(number)||(type==='integer'&&!Number.isInteger(number)))throw new Error('Bitte eine gültige Zahl eingeben.'); + return number; + } + return input.value; +} +function wireDataEditors(container) { + if(container.dataset.dataEditorsBound)return; + container.dataset.dataEditorsBound='true'; + container.addEventListener('click',event=>{ + const button=event.target.closest('[data-edit-action]'); + if(!button||!container.contains(button))return; + if(handledEditorEvents.has(event))return; + handledEditorEvents.add(event); + event.preventDefault(); + const action=button.dataset.editAction; + const node=button.closest('[data-value-node]'); + const schema=JSON.parse(node.dataset.valueSchema||'{}'); + const entry=button.closest('[data-entry]'); + if(action==='remove'){entry.remove();return;} + if(action==='up'){if(entry.previousElementSibling)entry.previousElementSibling.before(entry);return;} + if(action==='down'){if(entry.nextElementSibling)entry.nextElementSibling.after(entry);return;} + if(action==='add') { + const object=node.dataset.valueKind==='object'; + const content=node.querySelector(':scope > .data-node-content'); + const children=content.querySelector(':scope > .data-children'); + const key=content.querySelector(':scope > .data-add [data-new-property]')?.value||''; + if(object&&key&&[...children.children].some(row=>row.querySelector('[data-entry-key]')?.value===key)){toast('Dieses Feld ist bereits vorhanden.',true);return;} + const childSchema=object?schema.properties?.[key]||{}:schema.items||{}; + const type=editorSchemaType(childSchema,''); + children.insertAdjacentHTML('beforeend',dataEntry(key,editorDefault(type,childSchema),object,childSchema,children.children.length,(schema.required||[]).includes(key),object&&Object.hasOwn(schema.properties||{},key))); + children.lastElementChild.querySelector('input,select,textarea')?.focus(); + } + }); + container.addEventListener('change',event=>{ + if(!event.target.matches('[data-value-type]'))return; + if(handledEditorEvents.has(event))return; + handledEditorEvents.add(event); + const node=event.target.closest('[data-value-node]'); + const type=event.target.value; + node.dataset.valueKind=type; + node.querySelector(':scope > .data-node-content').innerHTML=dataNodeContent(editorDefault(type),type,JSON.parse(node.dataset.valueSchema||'{}'),'Wert'); + }); +} +function dataView(value) { + if(value===null||value===undefined)return 'Nicht gesetzt'; + if(typeof value==='boolean')return `${value?'Ja':'Nein'}`; + if(Array.isArray(value))return value.length?`
    ${value.map(item=>`
  1. ${dataView(item)}
  2. `).join('')}
`:'Keine Einträge'; + if(typeof value==='object')return Object.keys(value).length?`
${Object.entries(value).map(([key,item])=>`
${esc(editorLabel(key))}
${dataView(item)}
`).join('')}
`:'Keine zusätzlichen Einstellungen'; + return `${esc(value)}`; +} diff --git a/provisioner/static/forms.css b/provisioner/static/forms.css new file mode 100644 index 0000000..f8c598a --- /dev/null +++ b/provisioner/static/forms.css @@ -0,0 +1,16 @@ +.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +#modal{width:min(980px,calc(100vw - 28px));max-width:980px} +.form-section{min-width:0;border:1px solid var(--line);background:#fbfcfe;border-radius:10px;padding:20px;margin:0} +.form-section h3,.section-heading h3{font-size:14px;margin:0 0 14px;color:#36445a} +.form-section>.form-grid{margin-top:12px}.form-section>summary{font-weight:600;font-size:13px;cursor:pointer}.form-section[open]>summary{margin-bottom:16px} +.section-heading,.data-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.section-heading h3{margin:0}.section-heading{margin-bottom:14px} +.field-help{font-size:11px;line-height:1.65;color:var(--muted);margin:6px 0 12px} +.data-editor{min-width:0}.data-node{min-width:0}.data-heading{margin-bottom:10px}.data-heading strong{font-size:12px;font-weight:600}.data-kind{font-size:10px;color:var(--muted);background:#eef1f7;padding:4px 8px;border-radius:4px} +.data-kind-picker select{font-size:10px;padding:5px 8px;min-height:28px;max-width:130px}.data-entry{display:flex;gap:12px;align-items:flex-start;min-width:0;background:#fff;border:1px solid #e6eaf1;border-radius:8px;padding:14px;margin:10px 0} +.data-key-label{flex:0 1 145px;min-width:85px;font-size:10px}.data-entry-value{flex:1;min-width:0}.data-entry-actions{display:flex;align-items:center;gap:2px;flex-shrink:0}.data-entry-actions .icon-button{font-size:20px;padding:4px;min-width:27px} +.data-add{display:flex;align-items:flex-end;gap:10px;margin-top:12px}.data-add-choice{max-width:260px;min-width:0}.data-add-choice select{min-height:32px;font-size:11px}.data-node textarea{resize:vertical;min-height:36px;font-size:12px}.data-node .checkbox{padding-top:5px} +.data-view{display:grid;grid-template-columns:minmax(100px,170px) minmax(0,1fr);gap:10px 18px;margin:8px 0;font-size:12px;line-height:1.7}.data-view dt{color:var(--muted);overflow-wrap:anywhere}.data-view dd{margin:0;min-width:0;overflow-wrap:anywhere}.data-view .data-view{border-left:2px solid #edf0f5;padding-left:14px;grid-template-columns:minmax(90px,140px) minmax(0,1fr)}.data-view-list{padding-left:20px;margin:0}.data-view-list>li{margin:4px 0}.data-text{white-space:pre-wrap;overflow-wrap:anywhere} +.identity-row{display:grid;grid-template-columns:160px minmax(0,1fr) 30px;align-items:end;gap:12px;margin-bottom:14px}.identity-row .icon-button{align-self:center;margin-top:16px} +.form-section .form-section{background:#fff;margin-top:14px}.form-section .data-editor{margin-top:10px}.source-status{padding:12px 14px;background:#eef7f2;border:1px solid #d9eadf;border-radius:7px;font-size:12px}.source-status:empty{display:none}.script-preview{max-height:240px;overflow:auto} +[data-postinstall-editor]{grid-column:1/-1;min-width:0}[data-step-key]{border:1px solid var(--line);border-radius:10px;padding:18px;margin:14px 0;background:#fbfcfe} +@media(max-width:760px){#modal{width:calc(100vw - 16px);max-height:calc(100dvh - 16px)}.form-section{padding:14px}.data-entry{flex-wrap:wrap;padding:10px;gap:10px}.data-key-label{flex-basis:calc(100% - 90px)}.data-entry-value{flex-basis:100%;order:2}.data-entry-actions{margin-left:auto}.data-view,.data-view .data-view{grid-template-columns:1fr;gap:3px}.data-view dd{margin-bottom:10px}.identity-row{grid-template-columns:minmax(0,1fr) 30px}.identity-row>label:first-child{grid-column:1/-1}.identity-row .icon-button{margin-top:0}.data-add{flex-wrap:wrap}.data-add-choice{max-width:100%}} diff --git a/provisioner/static/host-forms.js b/provisioner/static/host-forms.js new file mode 100644 index 0000000..35861ca --- /dev/null +++ b/provisioner/static/host-forms.js @@ -0,0 +1,55 @@ +'use strict'; + +function identityRow(identity={kind:'serial',value:''}) { + return `
${field('identity_kind','Identität',identity.kind,{type:'select',options:[{value:'serial',label:'Seriennummer'},{value:'uuid',label:'System-UUID'},{value:'mac',label:'MAC-Adresse'}]})}${field('identity_value','Wert',identity.value,{required:true})}
`; +} +function identitiesEditor(identities=[]) { + return `

Hardware erkennen

Mindestens eine Kennung ordnet diesen Server beim Start zu.

${(identities.length?identities:[{kind:'serial',value:''}]).map(identityRow).join('')}
`; +} +function readIdentities(container) { + const identities=[...container.querySelectorAll('[data-identity-row]')].map(row=>({kind:row.querySelector('[name="identity_kind"]').value,value:row.querySelector('[name="identity_value"]').value.trim()})); + if(!identities.length||identities.some(item=>!item.value))throw new Error('Bitte mindestens eine vollständige Hardware-Kennung eintragen.'); + return identities; +} +function wireHostRows(container) { + if(container.dataset.hostRowsBound)return; + container.dataset.hostRowsBound='true'; + container.addEventListener('click',event=>{ + const button=event.target.closest('[data-host-action]'); + if(!button)return; + event.preventDefault(); + if(button.dataset.hostAction==='remove-identity')button.closest('[data-identity-row]').remove(); + if(button.dataset.hostAction==='add-identity')button.closest('[data-identities]').querySelector('[data-identity-rows]').insertAdjacentHTML('beforeend',identityRow()); + if(button.dataset.hostAction==='remove-host')button.closest('[data-import-host]').remove(); + if(button.dataset.hostAction==='add-host')container.querySelector('[data-import-hosts]').insertAdjacentHTML('beforeend',importHostRow()); + }); +} +async function graphicalHostForm(existing=null,discovery=null) { + const [profiles,isos,secrets]=await Promise.all([api('/profiles'),api('/iso-records'),api('/secrets')]); + const h=existing||discovery||{}; + const withMissing=(items,id)=>id&&!items.some(item=>item.id===id)?[...items,{id,name:'Bestehende Zuordnung (nicht mehr verfügbar)'}]:items; + const fields=`

Server

${field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})}${field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})}${field('management_ip','Management-IP mit Präfix',h.management_ip,{placeholder:'192.0.2.10/24',hint:'Zum Erfassen optional; vor der Installation erforderlich.',full:true})}${dataEditor('host-tags',arr(h.tags),{label:'Tags',schema:{type:'array',items:{type:'string'}}})}
${identitiesEditor(arr(h.identities))}

Installation zuweisen

${field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),h.installation_profile_id),'Noch nicht zuweisen')})}${field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),h.postinstall_profile_id),'Noch nicht zuweisen')})}${field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(withMissing(arr(isos),h.iso_id),'Noch nicht zuweisen')})}
Abweichende Einstellungen für diesen Server

Ohne Abweichung gelten die Werte des zugewiesenen Profils.

${installationEditor(h.overrides||{},{prefix:'host-override',override:true,secrets:arr(secrets)})}
`; + showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{ + const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip')||null,tags:readDataEditor(modal,'host-tags'),identities:readIdentities(modal),installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:readInstallationEditor(modal,'host-override',h.overrides||{})}; + if(existing){for(const key of Object.keys(body))if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;} + await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh(); + },'INVENTAR'); + wireDataEditors(modal); + wireHostRows(modal); + wireInstallationEditor(modal,'host-override'); +} +function importHostRow() { + return `

Server

${field('import_fqdn','Vollständiger Hostname','',{required:true,placeholder:'pve-01.example.net'})}${field('import_ip','Management-IP mit Präfix','',{placeholder:'192.0.2.10/24'})}${identitiesEditor()}
`; +} +async function graphicalHostImport() { + const [profiles,isos]=await Promise.all([api('/profiles'),api('/iso-records')]); + const fields=field('site','Gemeinsamer Standort','',{required:true,full:true})+field('installation_profile_id','Installationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium','',{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+`
${importHostRow()}
`; + showModal('Mehrere Server erfassen',form(fields,'Server anlegen'),async data=>{ + const rows=[...modal.querySelectorAll('[data-import-host]')]; + if(!rows.length||rows.length>100)throw new Error('Bitte zwischen einem und 100 Servern erfassen.'); + const common={site:data.get('site'),installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null}; + const hosts=rows.map(row=>({...common,fqdn:row.querySelector('[name="import_fqdn"]').value,management_ip:row.querySelector('[name="import_ip"]').value||null,identities:readIdentities(row)})); + await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server angelegt.`);await refresh(); + },'SERVERINVENTAR'); + wireHostRows(modal); +} diff --git a/provisioner/static/installation-form.js b/provisioner/static/installation-form.js new file mode 100644 index 0000000..f45f066 --- /dev/null +++ b/provisioner/static/installation-form.js @@ -0,0 +1,296 @@ +/* Graphical installation settings. The server validates the resolved host configuration. */ +const installationForm = (() => { + const clone = value => JSON.parse(JSON.stringify(value)); + const object = (value, label) => { + if (value === undefined) return {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: Erwartet werden benannte Einstellungen.`); + return value; + }; + const keyboards = [ + ['de','Deutsch'],['de-ch','Deutsch (Schweiz)'],['dk','Dänisch'],['en-gb','Englisch (Großbritannien)'], + ['en-us','Englisch (USA)'],['es','Spanisch'],['fi','Finnisch'],['fr','Französisch'], + ['fr-be','Französisch (Belgien)'],['fr-ca','Französisch (Kanada)'],['fr-ch','Französisch (Schweiz)'], + ['hu','Ungarisch'],['is','Isländisch'],['it','Italienisch'],['jp','Japanisch'],['lt','Litauisch'], + ['mk','Mazedonisch'],['nl','Niederländisch'],['no','Norwegisch'],['pl','Polnisch'], + ['pt','Portugiesisch'],['pt-br','Portugiesisch (Brasilien)'],['se','Schwedisch'],['si','Slowenisch'],['tr','Türkisch'] + ]; + const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' '); + const countries = (() => { + const names = typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['de'],{type:'region'}) : null; + return countryCodes.map(code => [code.toLowerCase(),names?.of(code) || ({DE:'Deutschland',AT:'Österreich',CH:'Schweiz'}[code] || code)]) + .sort((a,b) => a[1].localeCompare(b[1],'de')); + })(); + const timezones = (() => { + const regions = {Africa:'Afrika',America:'Amerika',Antarctica:'Antarktis',Arctic:'Arktis',Asia:'Asien',Atlantic:'Atlantik',Australia:'Australien',Europe:'Europa',Indian:'Indischer Ozean',Pacific:'Pazifik'}; + const supported = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []; + return [...new Set(['Europe/Berlin','UTC',...supported])].map(zone => [zone,zone === 'UTC' ? 'UTC (koordinierte Weltzeit)' : zone.split('/').map((part,index) => index === 0 ? regions[part] || part : part.replaceAll('_',' ')).join(' / ')]) + .sort((a,b) => a[1].localeCompare(b[1],'de')); + })(); + const interfaces = [ + ['ID_NET_NAME_MAC','Name aus MAC-Adresse'], ['ID_NET_NAME_PATH','Name aus Gerätepfad'], + ['ID_NET_NAME_SLOT','Name des Steckplatzes'], ['ID_NET_NAME_ONBOARD','Name der Onboard-Schnittstelle'], + ['INTERFACE','Schnittstellenname'], ['ID_PATH','Gerätepfad'], ['ID_NET_DRIVER','Treiber'] + ]; + const raids = [['raid0','RAID0 / Einzelplatte'],['raid1','RAID1 / Spiegel'],['raid10','RAID10'],['raidz-1','RAIDZ1'],['raidz-2','RAIDZ2'],['raidz-3','RAIDZ3']]; + const zfsNumbers = [['ashift','Sektorgröße (ashift)',9,16,true],['arc-max','Maximaler ARC in MiB',64,1048576,true],['copies','Datenkopien',1,3,true],['hdsize','Verwendete Plattengröße in GiB',2,1000000,false]]; + const lvmNumbers = [['hdsize','Verwendete Plattengröße in GiB',2],['swapsize','Swap-Größe in GiB',0],['maxroot','Maximale Root-Größe in GiB',2],['maxvz','Maximale Daten-Größe in GiB',0],['minfree','Freier Platz in GiB',0]]; + const optionsFor = (entries, current, blank = 'Nicht festlegen') => { + const items = entries.map(item => Array.isArray(item) ? {value:item[0],label:item[1]} : {value:item,label:item}); + if (current !== undefined && current !== '' && !items.some(item => String(item.value) === String(current))) items.push({value:current,label:`Bestehender Wert: ${current}`}); + return [{value:'',label:blank}, ...items]; + }; + function root(container, prefix) { + const nodes = [...(container.matches?.('[data-installation-editor]') ? [container] : []), ...container.querySelectorAll('[data-installation-editor]')]; + const found = nodes.find(node => node.dataset.installationEditor === prefix); + if (!found) throw new Error('Das Installationsformular wurde nicht gefunden. Bitte erneut öffnen.'); + return found; + } + function text(value, label) { + if (value === undefined) return ''; + if (typeof value !== 'string') throw new Error(`${label}: Der gespeicherte Wert muss Text sein.`); + return value; + } + function strings(value, label) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}: Die gespeicherten Einträge müssen Text sein.`); + return value; + } + function numeric(value, label) { + if (value === undefined) return ''; + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label}: Der gespeicherte Wert muss eine Zahl sein.`); + return value; + } + function networkRow(prefix, key = 'ID_NET_NAME_MAC', value = '') { + const mac = key === 'ID_NET_NAME_MAC' && (!value || /^enx[0-9a-f]{12}$/.test(value)); + const known = interfaces.some(item => item[0] === key); + const selected = mac ? '__mac' : known ? key : '__custom'; + if (mac && value) value = value.slice(3).match(/../g).join(':'); + return `
+ ${field(`${prefix}.interface-key`,'Schnittstelle erkennen über',selected,{type:'select',options:optionsFor([['__mac','MAC-Adresse'],...interfaces,['__custom','Anderes Merkmal']],selected)})} + ${field(`${prefix}.interface-value`,'MAC-Adresse oder erwarteter Wert',text(value,'Schnittstellenmerkmal'),{placeholder:mac?'02:00:00:00:00:01':'Erwarteter Merkmalswert'})} +
${field(`${prefix}.interface-custom`,'Name des anderen Merkmals',known?'':key,{full:true})}
+ +
`; + } + function render(values = {}, options = {}) { + const prefix = options.prefix || 'installation', override = Boolean(options.override); + values = clone(object(values,'Installationskonfiguration')); + const fresh = !override && Object.keys(values).length === 0; + if (fresh) values = {global:{keyboard:'de',country:'de',timezone:'Europe/Berlin'},network:{source:'from-answer'},disk_setup:{filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}}}; + const global = object(values.global,'Allgemeine Einstellungen'), network = object(values.network,'Managementnetz'); + const disks = object(values.disk_setup,'Datenträger'), zfs = object(disks.zfs,'ZFS'), lvm = object(disks.lvm,'LVM'); + const netFilter = object(network.filter,'Schnittstellenmerkmale'), diskFilter = object(disks.filter,'Datenträgerfilter'); + if (Object.keys(diskFilter).length > 1) throw new Error('Die Datenträgerauswahl enthält mehrere Filter. Es wird genau ein Seriennummern- oder WWN-Filter unterstützt.'); + if (global['reboot-on-error'] !== undefined && typeof global['reboot-on-error'] !== 'boolean') throw new Error('Neustart bei Fehler: Der gespeicherte Wert muss Ja oder Nein sein.'); + const input = (name,label,value,settings={}) => field(`${prefix}.${name}`,label,text(value,label),{...settings}); + const select = (name,label,value,entries,settings={}) => input(name,label,value,{type:'select',options:optionsFor(entries,value,override?'Vom Profil übernehmen':'Nicht festlegen'),...settings}); + const number = (name,label,value,min,max,integer=false) => field(`${prefix}.${name}`,label,numeric(value,label),{type:'number',min,max,hint:'Leer lassen: vorhandene Vorgabe verwenden.'}).replace('type="number"',`type="number" step="${integer?'1':'any'}"`); + const section = (name,title,body) => `
+ ${override?field(`${prefix}.enable.${name}`,`${title} für diesen Host festlegen`,Object.hasOwn(values,name),{type:'checkbox',full:true}):`

${esc(title)}

`} +
${body}
`; + const required = !override; + const secretId = text(values.root_secret_id,'Root-Geheimnis'); + const secrets = (options.secrets || []).map(secret => [secret.id, secret.name || secret.id]); + if (secretId && !secrets.some(secret => secret[0] === secretId)) secrets.push([secretId,`Bisherige Referenz (${secretId})`]); + const diskMode = disks.selection !== undefined ? disks.selection : Object.keys(disks).some(key => ['filter','filter_match','expected_serials','expected_count'].includes(key)) ? 'filtered' : ''; + const diskKey = Object.keys(diskFilter)[0] || (override ? '' : 'ID_SERIAL_SHORT'); + const diskPattern = diskKey ? diskFilter[diskKey] : undefined; + const networkRows = Object.entries(netFilter).map(([key,value]) => networkRow(prefix,key,value)).join('') || (override?'':networkRow(prefix)); + const reboot = global['reboot-on-error'] === undefined ? '' : String(global['reboot-on-error']); + const general = select('global.keyboard','Tastatur',global.keyboard,keyboards,{required}) + + select('global.country','Land',global.country,countries,{required}) + + select('global.timezone','Zeitzone',global.timezone,timezones,{required}) + + input('global.mailto','E-Mail für Systemmeldungen',global.mailto,{required,type:'email'}) + + input('global.fqdn','Hostname-Vorgabe (optional)',global.fqdn,{hint:'Der tatsächlich zugewiesene Server liefert seinen vollständigen Hostnamen.'}) + + select('global.reboot-on-error','Bei Installationsfehler neu starten',reboot,[['false','Nein'],['true','Ja']]) + + dataEditor(`${prefix}.global.root-ssh-keys`,strings(global['root-ssh-keys'],'SSH-Schlüssel'),{label:'Öffentliche SSH-Schlüssel für root',schema:{type:'array',description:'Für jeden öffentlichen Schlüssel einen Eintrag hinzufügen. Keine privaten Schlüssel hinterlegen.',items:{type:'string',title:'Öffentlicher SSH-Schlüssel',minLength:1}}}); + const networking = select('network.source','Netzwerkkonfiguration',network.source,[['from-answer','Festes Managementnetz aus diesem Profil']],{required}) + + input('network.gateway','Standardgateway',network.gateway,{required}) + + input('network.dns','DNS-Server',network.dns,{required}) + + input('network.cidr','Management-IP mit Präfix (optional)',network.cidr,{hint:'Die tatsächliche IP mit Präfix kommt aus dem Serverinventar.'}) + + `

Management-Schnittstelle

Ein oder mehrere Merkmale der gewünschten Netzwerkkarte. Der Installer verwendet diese zur Auswahl.

${networkRows}
`; + const filtering = select('disk.filter.key','Datenträger erkennen über',diskKey,[['ID_SERIAL_SHORT','Seriennummer'],['ID_SERIAL','Vollständige Serienkennung'],['ID_WWN','WWN']],{required}) + + input('disk.filter.value','Seriennummer oder Suchmuster',diskPattern,{required,hint:'Ein konkreter Wert oder ein Muster für die unten bestätigten Kennungen.'}) + + select('disk.filter_match','Filterverknüpfung',disks.filter_match,[['all','Alle Merkmale'],['any','Mindestens ein Merkmal']]) + + number('disk.expected_count','Anzahl bestätigter Datenträger',disks.expected_count,1,16,true) + + dataEditor(`${prefix}.disk.expected_serials`,strings(disks.expected_serials,'Datenträgerkennungen'),{label:'Bestätigte Seriennummern oder WWNs',schema:{type:'array',description:'Jeden bestätigten Datenträger als eigenen Eintrag hinzufügen. Die Anzahl wird aus der Liste übernommen, wenn das Anzahlfeld leer bleibt; die tatsächliche Hardware wird dadurch nicht gezählt.',items:{type:'string',title:'Seriennummer oder WWN',minLength:1}}}); + const zfsFields = select('disk.zfs.raid','ZFS-Verbund',zfs.raid,raids,{required}) + + zfsNumbers.map(([key,label,min,max,integer]) => number(`disk.zfs.${key}`,label,zfs[key],min,max,integer)).join('') + + select('disk.zfs.checksum','Prüfsummen',zfs.checksum,[['on','Standard'],['fletcher4','Fletcher4'],['sha256','SHA-256']]) + + select('disk.zfs.compress','Kompression',zfs.compress,[['on','Standard'],['off','Aus'],['lzjb','LZJB'],['lz4','LZ4'],['zle','ZLE'],['gzip','Gzip'],['zstd','Zstandard']]); + const storage = select('disk.filesystem','Dateisystem',disks.filesystem,[['zfs','ZFS'],['ext4','ext4 / LVM'],['xfs','XFS / LVM']],{required}) + + select('disk.mode','Datenträgerauswahl',diskMode,[['all','Automatisch – Server mit einer Zielplatte'],['filtered','Über Seriennummer oder WWN auswählen']],{required}) + + `
Für Server mit genau einer Zielplatte: Es werden alle vom Installer erkannten Zielplatten verwendet. Seriennummer und Gerätename sind nicht erforderlich. Die tatsächliche Anzahl wird nicht geprüft.
` + + `
${filtering}
` + + input('disk.inventory_evidence','Referenz zur Hardwareprüfung',disks.inventory_evidence,{full:true,hint:'Bei Auswahl per Seriennummer oder WWN erforderlich; bei automatischer Auswahl optional.'}) + + `
${zfsFields}
` + + `
${lvmNumbers.map(([key,label,min]) => number(`disk.lvm.${key}`,label,lvm[key],min,1000000)).join('')}
`; + return `
+ ${override?'

Nicht aktivierte Bereiche und leere Felder übernehmen die veröffentlichten Profilwerte. Gesperrte Profilfelder können nicht überschrieben werden.

':''} +
${section('global','Allgemeine Einstellungen',general)} + ${section('root_secret_id','Root-Zugang',select('root_secret_id','Gespeichertes Root-Geheimnis',secretId,secrets,{required,full:true,hint:'Root-Zugang vorher unter Einstellungen → Geheimnisse anlegen.'}))} + ${section('network','Managementnetz',networking)}${section('disk_setup','Systemdatenträger',storage)}
`; + } + function read(container, prefix = 'installation', previousValues = {}) { + const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState); + const values = clone(object(previousValues,'Installationskonfiguration')); + const before = state.values; + const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`); + const value = name => (control(name)?.value || '').trim(); + const enabled = name => !state.override || Boolean(control(`enable.${name}`)?.checked); + const assign = (target,key,next) => {if (next === undefined) delete target[key]; else target[key] = next;}; + const string = name => value(name) || undefined; + const numericValue = (name,label,min,max,integer=false) => { + const node = control(name), raw = value(name); + if (node?.validity?.badInput) throw new Error(`${label}: Bitte eine gültige Zahl eingeben.`); + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < min || parsed > max || (integer && !Number.isInteger(parsed))) throw new Error(`${label}: ${integer?'Eine ganze Zahl':'Eine Zahl'} zwischen ${min} und ${max} eingeben.`); + if (name.startsWith('disk.lvm.') && parsed >= 1000000) throw new Error(`${label}: Die Größe muss kleiner als 1000000 GiB sein.`); + return parsed; + }; + const list = (name,previous) => { + const result = readDataEditor(editor,`${prefix}.${name}`); + if (!Array.isArray(result) || result.some(item => typeof item !== 'string' || !item.trim())) throw new Error(`${name.includes('ssh')?'SSH-Schlüssel':'Datenträgerkennungen'}: Jeden Eintrag ausfüllen oder den leeren Eintrag entfernen.`); + return result.length || Array.isArray(previous) && previous.length === 0 ? result : undefined; + }; + const base = name => clone(object(Object.hasOwn(values,name)?values[name]:before[name],name)); + if (enabled('global')) { + const global = base('global'); + for (const key of ['keyboard','country','timezone','mailto','fqdn']) assign(global,key,string(`global.${key}`)); + const reboot = value('global.reboot-on-error'); + if (reboot && !['true','false'].includes(reboot)) throw new Error('Neustart bei Fehler: Bitte Ja, Nein oder Übernehmen wählen.'); + assign(global,'reboot-on-error',reboot ? reboot === 'true' : undefined); + assign(global,'root-ssh-keys',list('global.root-ssh-keys',before.global?.['root-ssh-keys'])); + values.global = global; + } else delete values.global; + if (enabled('root_secret_id')) assign(values,'root_secret_id',string('root_secret_id')); + else delete values.root_secret_id; + if (enabled('network')) { + const network = base('network'); + for (const key of ['source','gateway','dns','cidr']) assign(network,key,string(`network.${key}`)); + const filter = Object.create(null); + for (const row of editor.querySelectorAll('[data-installation-network-row]')) { + const input = suffix => [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${suffix}`)?.value.trim() || ''; + const selected = input('interface-key'), key = selected === '__custom' ? input('interface-custom') : selected === '__mac' ? 'ID_NET_NAME_MAC' : selected; + let expected = input('interface-value'); + if (!expected && selected !== '__custom') continue; + if (!key || !expected) throw new Error('Management-Schnittstelle: Merkmal und erwarteten Wert vollständig ausfüllen.'); + if (selected === '__mac') { + const mac = expected.replace(/[:-]/g,'').toLowerCase(); + if (!/^[0-9a-f]{12}$/.test(mac)) throw new Error('Management-Schnittstelle: Eine gültige MAC-Adresse wie 02:00:00:00:00:01 eingeben.'); + expected = `enx${mac}`; + } + if (['__proto__','prototype','constructor'].includes(key) || Object.hasOwn(filter,key)) throw new Error('Management-Schnittstelle: Jedes gültige Merkmal darf nur einmal vorkommen.'); + filter[key] = expected; + } + const keepEmptyFilter = before.network?.filter && Object.keys(before.network.filter).length === 0; + if (!state.override && !Object.keys(filter).length) throw new Error('Bitte die MAC-Adresse oder ein anderes Merkmal der Management-Schnittstelle angeben.'); + assign(network,'filter',Object.keys(filter).length || keepEmptyFilter ? {...filter} : undefined); + values.network = network; + } else delete values.network; + if (enabled('disk_setup')) { + const disks = base('disk_setup'), filesystem = value('disk.filesystem'), mode = value('disk.mode'); + if (filesystem && !['zfs','ext4','xfs'].includes(filesystem)) throw new Error('Bitte ZFS, ext4 oder XFS auswählen.'); + if (mode && !['all','filtered'].includes(mode)) throw new Error('Bitte eine gültige Datenträgerauswahl treffen.'); + assign(disks,'filesystem',filesystem || undefined); + assign(disks,'inventory_evidence',string('disk.inventory_evidence')); + if (mode === 'all') { + const raid = value('disk.zfs.raid'); + if ((filesystem && filesystem !== 'zfs') || (raid && raid !== 'raid0') || (!state.override && (!filesystem || !raid))) throw new Error('Die automatische Datenträgerauswahl benötigt ZFS mit RAID0.'); + disks.selection = 'all'; + for (const key of ['filter','filter_match','expected_count','expected_serials']) delete disks[key]; + } else { + delete disks.selection; + const key = value('disk.filter.key'), pattern = value('disk.filter.value'); + if ((key && pattern) && !['ID_SERIAL','ID_SERIAL_SHORT','ID_WWN'].includes(key)) throw new Error('Bitte Seriennummer oder WWN zur Datenträgerauswahl verwenden.'); + if (mode === 'filtered' && !state.override && (!key || !pattern)) throw new Error('Bitte ein Datenträgermerkmal und einen Seriennummern- oder WWN-Filter eingeben.'); + assign(disks,'filter',key && pattern ? {[key]:pattern} : undefined); + assign(disks,'filter_match',string('disk.filter_match')); + const serials = list('disk.expected_serials',before.disk_setup?.expected_serials); + if (mode === 'filtered' && !state.override && !serials?.length) throw new Error('Bitte mindestens eine bestätigte Datenträgerkennung hinzufügen.'); + if (serials && (serials.length > 16 || new Set(serials).size !== serials.length || serials.some(item => /[*?\[\]]/.test(item)))) throw new Error('Datenträgerkennungen: Höchstens 16 unterschiedliche, konkrete Seriennummern oder WWNs eingeben.'); + const count = numericValue('disk.expected_count','Anzahl bestätigter Datenträger',1,16,true); + if (count !== undefined && serials && count !== serials.length) throw new Error('Die Anzahl muss zur Liste der bestätigten Datenträger passen.'); + assign(disks,'expected_serials',serials); + assign(disks,'expected_count',count ?? (!state.override && serials?.length ? serials.length : undefined)); + if (disks.filter_match && !['all','any'].includes(disks.filter_match)) throw new Error('Bitte eine gültige Filterverknüpfung wählen.'); + if (disks.inventory_evidence && disks.inventory_evidence.length < 5) throw new Error('Die Referenz zur Hardwareprüfung muss mindestens fünf Zeichen enthalten.'); + } + if (filesystem === 'zfs' || !filesystem && (before.disk_setup?.zfs || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.zfs.`) && node.value))) { + const zfs = clone(object(disks.zfs,'ZFS')); + assign(zfs,'raid',string('disk.zfs.raid')); + for (const [key,label,min,max,integer] of zfsNumbers) assign(zfs,key,numericValue(`disk.zfs.${key}`,label,min,max,integer)); + for (const key of ['checksum','compress']) assign(zfs,key,string(`disk.zfs.${key}`)); + if (zfs.raid && !raids.some(([raid]) => raid === zfs.raid)) throw new Error('Bitte einen unterstützten ZFS-Verbund auswählen.'); + if (zfs.checksum && !['on','fletcher4','sha256'].includes(zfs.checksum)) throw new Error('Bitte eine unterstützte ZFS-Prüfsumme auswählen.'); + if (zfs.compress && !['on','off','lzjb','lz4','zle','gzip','zstd'].includes(zfs.compress)) throw new Error('Bitte eine unterstützte ZFS-Kompression auswählen.'); + const count = disks.expected_serials?.length, minimum = {raid0:1,raid1:2,raid10:4,'raidz-1':3,'raidz-2':4,'raidz-3':5}; + if (mode === 'filtered' && count && zfs.raid && (count < minimum[zfs.raid] || zfs.raid === 'raid10' && count % 2)) throw new Error('Die bestätigte Datenträgeranzahl passt nicht zum gewählten ZFS-Verbund.'); + disks.zfs = zfs; + if (filesystem) delete disks.lvm; + } else if (filesystem) delete disks.zfs; + if (['ext4','xfs'].includes(filesystem) || !filesystem && (before.disk_setup?.lvm || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.lvm.`) && node.value))) { + if (disks.expected_serials?.length > 1) throw new Error('ext4 und XFS benötigen genau einen bestätigten Systemdatenträger.'); + const lvm = clone(object(disks.lvm,'LVM')); + for (const [key,label,min] of lvmNumbers) assign(lvm,key,numericValue(`disk.lvm.${key}`,label,min,1000000)); + if (Object.keys(lvm).length || Object.hasOwn(before.disk_setup || {},'lvm')) disks.lvm = lvm; + else delete disks.lvm; + } else if (filesystem) delete disks.lvm; + values.disk_setup = disks; + } else delete values.disk_setup; + if (state.override) for (const key of ['global','network','disk_setup']) { + if (values[key] && !Object.keys(values[key]).length && (!Object.hasOwn(before,key) || Object.keys(before[key]).length)) delete values[key]; + } + return values; + } + function wire(container, prefix = 'installation') { + const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState); + if (editor.dataset.installationWired) return; + editor.dataset.installationWired = 'true'; + wireDataEditors(editor); + const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`); + const visible = (node,show) => {node.hidden = !show; for (const item of node.querySelectorAll('input,select,textarea,button')) item.disabled = !show;}; + function refresh() { + for (const section of editor.querySelectorAll('[data-installation-section]')) { + const enabled = !state.override || control(`enable.${section.dataset.installationSection}`).checked; + visible(section.querySelector('[data-installation-section-body]'),enabled); + } + const diskEnabled = !state.override || control('enable.disk_setup').checked; + const filesystem = control('disk.filesystem').value, mode = control('disk.mode').value; + visible(editor.querySelector('[data-installation-filter-fields]'),diskEnabled && mode !== 'all'); + visible(editor.querySelector('[data-installation-zfs-fields]'),diskEnabled && (!filesystem || filesystem === 'zfs')); + visible(editor.querySelector('[data-installation-lvm-fields]'),diskEnabled && (!filesystem || ['ext4','xfs'].includes(filesystem))); + editor.querySelector('[data-installation-all-note]').hidden = !diskEnabled || mode !== 'all'; + control('disk.inventory_evidence').required = !state.override && mode === 'filtered'; + for (const row of editor.querySelectorAll('[data-installation-network-row]')) { + const select = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-key`); + const expected = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-value`); + expected.placeholder = select.value === '__mac' ? '02:00:00:00:00:01' : 'Erwarteter Merkmalswert'; + visible(row.querySelector('[data-installation-custom-key]'),(!state.override || control('enable.network').checked) && select.value === '__custom'); + } + } + editor.addEventListener('change', event => { + const name = event.target.name; + if (name === `${prefix}.disk.mode` && event.target.value === 'all') { + control('disk.filesystem').value = 'zfs'; + control('disk.zfs.raid').value = 'raid0'; + } + if (name === `${prefix}.disk.filesystem` && event.target.value && event.target.value !== 'zfs' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered'; + if (name === `${prefix}.disk.zfs.raid` && event.target.value !== 'raid0' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered'; + refresh(); + }); + editor.addEventListener('click', event => { + const add = event.target.closest('[data-installation-add-interface]'), remove = event.target.closest('[data-installation-remove-interface]'); + if (add) editor.querySelector('[data-installation-network-rows]').insertAdjacentHTML('beforeend',networkRow(prefix)); + if (remove) remove.closest('[data-installation-network-row]').remove(); + if (add || remove) {event.preventDefault(); refresh();} + }); + refresh(); + } + return {render,read,wire}; +})(); + +function installationEditor(values = {}, options = {}) { return installationForm.render(values,options); } +function readInstallationEditor(container,prefix = 'installation',previousValues = {}) { return installationForm.read(container,prefix,previousValues); } +function wireInstallationEditor(container,prefix = 'installation') { installationForm.wire(container,prefix); } diff --git a/provisioner/static/module-forms.js b/provisioner/static/module-forms.js new file mode 100644 index 0000000..7619977 --- /dev/null +++ b/provisioner/static/module-forms.js @@ -0,0 +1,392 @@ +/* Graphical module parameters and ordered postinstallation steps. */ +'use strict'; + +const moduleFormTools = (() => { + let sequence = 0; + const uid = prefix => `${prefix}-${++sequence}`; + const list = value => Array.isArray(value) ? value : value?.items || []; + const own = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key); + const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); + const metadata = value => esc(JSON.stringify(value)); + const original = element => JSON.parse(element.dataset.original || '{}'); + const fieldValue = (container, name) => [...container.querySelectorAll('[name]')].find(item => item.name === name); + const direct = (container, attribute) => [...container.children].find(item => item.hasAttribute(attribute)); + const button = (label, action, attributes = '') => ``; + const rootIn = (container, selector) => container.matches?.(selector) ? container : container.querySelector(selector); + const moduleName = module => `${module.name || 'Modul'} · v${module.version ?? '–'}`; + + function ensureDataEditors(root) { + for (let parent = root.parentElement; parent; parent = parent.parentElement) if (parent.dataset.dataEditorsBound) return; + wireDataEditors(root); + } + + function syncHiddenControls(root) { + for (const input of root.querySelectorAll('input,select,textarea')) input.disabled = Boolean(input.closest('[hidden]')); + } + + function showError(root, error) { + const element = direct(root, 'data-module-form-error'); + if (element) { element.textContent = error.message || String(error); element.hidden = false; } + } + + function clearError(root) { + const element = direct(root, 'data-module-form-error'); + if (element) { element.textContent = ''; element.hidden = true; } + } + + function secretRow(name, value, secrets) { + const key = uid('step-secret'); + const options = [{value:'', label:'Geheimnis auswählen'}, ...list(secrets).map(secret => ({value:secret.id, label:secret.name || 'Gespeichertes Geheimnis'}))]; + if (value && !options.some(item => item.value === value)) options.push({value, label:'Bisheriges Geheimnis (nicht in der Liste verfügbar)'}); + return `
${field(`${key}-name`, 'Bezeichnung im Modul', name, {placeholder:'z. B. API_KEY', hint:'Der vom Modul erwartete Parametername.'})}${field(`${key}-value`, 'Gespeichertes Geheimnis', value, {type:'select', options})}
${button('Geheimniszuordnung entfernen', 'remove-secret')}
`; + } + + function stepParameters(key, values, module) { + return dataEditor(`${key}-parameters`, values || {}, {label:'Parameter dieses Schritts', schema:module?.parameters_schema || {type:'object'}}); + } + + function stepCard(step, modules, secrets) { + const key = uid('postinstall-step'); + const published = list(modules).filter(item => item.status === 'published'); + const module = published.find(item => item.id === step.module_id); + const options = [{value:'', label:'Veröffentlichtes Modul auswählen'}, ...published.map(item => ({value:item.id, label:moduleName(item)}))]; + if (step.module_id && !module) options.push({value:step.module_id, label:'Bisherige Modulversion (nicht verfügbar)'}); + return `
+

Schritt

${button('↑ Nach oben', 'step-up')}${button('↓ Nach unten', 'step-down')}${button('Entfernen', 'remove-step')}
+
${field(`${key}-module`, 'Modul und Version', step.module_id || '', {type:'select', full:true, options})}${field(`${key}-required`, 'Pflichtschritt: Der Lauf ist nur bei erfolgreicher Prüfung abgeschlossen.', step.required !== false, {type:'checkbox', full:true})}
+
${stepParameters(key, step.parameters || {}, module)}
+ Geheimnisse für diesen Schritt

Gespeicherte Geheimnisse anhand ihres Namens zuordnen. Die Werte bleiben verborgen.

${Object.entries(step.secret_refs || {}).map(([name, value]) => secretRow(name, value, secrets)).join('')}
${button('Geheimnis zuordnen', 'add-secret')}
+
`; + } + + function postinstall(profile = {}, modules = [], secrets = []) { + return `
+ ${dataEditor('postinstall-values', profile.values || {}, {label:'Gemeinsame Profilparameter', schema:{type:'object'}})} +

Module auswählen und in der gewünschten Reihenfolge anordnen. Abhängigkeiten müssen vor dem jeweiligen Modul stehen.

+ ${list(modules).some(item => item.status === 'published') ? '' : '
Es sind noch keine veröffentlichten Module verfügbar. Übernehmen Sie zunächst eine Modulvorlage und veröffentlichen Sie den geprüften Stand.
'} +
${list(profile.steps).map(step => stepCard(step, modules, secrets)).join('')}
+

Fügen Sie den ersten Schritt hinzu. Mindestens ein Schritt muss als Pflichtschritt markiert sein.

+
${button('Schritt hinzufügen', 'add-step')}
+ ${field('postinstall-reboot-budget', 'Maximale geplante Neustarts', profile.reboot_budget ?? 1, {type:'number', min:0, max:5, full:true})} + +
`; + } + + function readStep(card, index, modules = null) { + const key = card.dataset.stepKey; + const saved = original(card); + const moduleId = fieldValue(card, `${key}-module`).value; + if (!moduleId) throw new Error(`Wählen Sie für Schritt ${index + 1} ein veröffentlichtes Modul aus.`); + if (modules && !list(modules).some(item => item.id === moduleId && item.status === 'published')) throw new Error(`Das Modul in Schritt ${index + 1} ist nicht verfügbar. Wählen Sie eine veröffentlichte Modulversion.`); + const parameters = readDataEditor(card, `${key}-parameters`); + if (!object(parameters)) throw new Error(`Die Parameter in Schritt ${index + 1} müssen eine Gruppe von Feldern sein.`); + const secretRefs = {}; + for (const row of card.querySelectorAll('[data-step-secret]')) { + const name = fieldValue(row, `${row.dataset.secretKey}-name`).value.trim(); + const value = fieldValue(row, `${row.dataset.secretKey}-value`).value; + if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name)) throw new Error(`Geben Sie für die Geheimniszuordnung in Schritt ${index + 1} einen gültigen Parameternamen ein (Buchstaben, Zahlen und Unterstrich).`); + if (!value) throw new Error(`Wählen Sie für „${name}“ in Schritt ${index + 1} ein gespeichertes Geheimnis aus.`); + if (own(secretRefs, name)) throw new Error(`Die Geheimnisbezeichnung „${name}“ ist in Schritt ${index + 1} doppelt vorhanden.`); + Object.defineProperty(secretRefs, name, {value, enumerable:true, configurable:true, writable:true}); + } + return {id:saved.id || card.dataset.stepId || (card.dataset.stepId = `step-${Date.now().toString(36)}-${uid('new')}`), module_id:moduleId, parameters, secret_refs:secretRefs, required:fieldValue(card, `${key}-required`).checked}; + } + + function readPostinstall(container, previous = {}) { + const root = rootIn(container, '[data-postinstall-editor]'); + if (!root) throw new Error('Der Editor für die Nachkonfiguration wurde nicht gefunden. Öffnen Sie das Formular erneut.'); + const steps = [...root.querySelector('[data-postinstall-steps]').children].map((card, index) => readStep(card, index, root._moduleFormsModules)); + if (!steps.length) throw new Error('Fügen Sie mindestens einen Schritt zur Nachkonfiguration hinzu.'); + if (steps.length > 50) throw new Error('Ein Profil darf höchstens 50 Schritte enthalten.'); + if (!steps.some(step => step.required)) throw new Error('Markieren Sie mindestens eine Abschlussprüfung als Pflichtschritt.'); + if (new Set(steps.map(step => step.id)).size !== steps.length) throw new Error('Das Profil enthält doppelte Schrittkennungen. Entfernen Sie den doppelten Schritt und fügen Sie ihn neu hinzu.'); + const values = readDataEditor(root, 'postinstall-values'); + if (!object(values)) throw new Error('Die gemeinsamen Profilparameter müssen eine Gruppe von Feldern sein.'); + const input = fieldValue(root, 'postinstall-reboot-budget'); + const rebootBudget = input ? Number(input.value) : previous.reboot_budget ?? 1; + if (input?.value === '' || !Number.isInteger(rebootBudget) || rebootBudget < 0 || rebootBudget > 5) throw new Error('Wählen Sie für geplante Neustarts eine ganze Zahl zwischen 0 und 5.'); + return {values, steps, reboot_budget:rebootBudget}; + } + + function renumberSteps(root) { + const steps = [...root.querySelector('[data-postinstall-steps]').children]; + steps.forEach((card, index) => { + card.querySelector('[data-step-heading]').textContent = `Schritt ${index + 1}`; + card.querySelector('[data-module-action="step-up"]').disabled = index === 0; + card.querySelector('[data-module-action="step-down"]').disabled = index === steps.length - 1; + }); + root.querySelector('[data-no-steps]').hidden = steps.length > 0; + root.querySelector('[data-module-action="add-step"]').disabled = steps.length >= 50; + } + + function wirePostinstall(container, modules, secrets) { + const root = rootIn(container, '[data-postinstall-editor]'); + if (!root) return; + root._moduleFormsModules = list(modules); + root._moduleFormsSecrets = list(secrets); + ensureDataEditors(root); + renumberSteps(root); + if (root.dataset.moduleFormsWired) return; + root.dataset.moduleFormsWired = 'true'; + root.addEventListener('click', event => { + const control = event.target.closest('[data-module-action]'); + if (!control || !root.contains(control)) return; + event.preventDefault(); + event.stopPropagation(); + clearError(root); + const card = control.closest('[data-postinstall-step]'); + const steps = root.querySelector('[data-postinstall-steps]'); + try { + switch (control.dataset.moduleAction) { + case 'add-step': + if (steps.children.length < 50) steps.insertAdjacentHTML('beforeend', stepCard({id:`step-${Date.now().toString(36)}-${uid('new')}`, parameters:{}, secret_refs:{}, required:true}, root._moduleFormsModules, root._moduleFormsSecrets)); + break; + case 'remove-step': card.remove(); break; + case 'step-up': if (card.previousElementSibling) steps.insertBefore(card, card.previousElementSibling); break; + case 'step-down': if (card.nextElementSibling) steps.insertBefore(card.nextElementSibling, card); break; + case 'add-secret': card.querySelector('[data-step-secrets]').insertAdjacentHTML('beforeend', secretRow('', '', root._moduleFormsSecrets)); break; + case 'remove-secret': control.closest('[data-step-secret]').remove(); break; + } + renumberSteps(root); + ensureDataEditors(root); + } catch (error) { showError(root, error); } + }); + root.addEventListener('change', event => { + const card = event.target.closest('[data-postinstall-step]'); + if (!card || event.target.name !== `${card.dataset.stepKey}-module`) return; + const holder = card.querySelector('[data-step-parameters]'); + const cache = card._moduleParameterCache || (card._moduleParameterCache = new Map()); + const previousId = card.dataset.currentModule; + const nextId = event.target.value; + const module = root._moduleFormsModules.find(item => item.id === nextId); + // Keep the actual fields, including incomplete edits, when switching away. + // Switching back restores their original DOM rather than recreating values. + const fragment = document.createDocumentFragment(); + let currentValues = {}; + try { currentValues = readDataEditor(card, `${card.dataset.stepKey}-parameters`); } catch (_) { /* Preserve invalid edits in the cached fields. */ } + while (holder.firstChild) fragment.append(holder.firstChild); + cache.set(previousId, fragment); + if (cache.has(nextId)) { + holder.append(cache.get(nextId)); + cache.delete(nextId); + } else { + const properties = module?.parameters_schema?.properties || {}; + const values = copy(module?.default_parameters || {}); + for (const [name, rule] of Object.entries(properties)) { + if (own(currentValues, name)) Object.defineProperty(values, name, {value:copy(currentValues[name]), enumerable:true, configurable:true, writable:true}); + else if (own(rule, 'default')) Object.defineProperty(values, name, {value:copy(rule.default), enumerable:true, configurable:true, writable:true}); + } + holder.innerHTML = stepParameters(card.dataset.stepKey, values, module); + } + card.dataset.currentModule = nextId; + ensureDataEditors(root); + clearError(root); + }); + } + + const types = [{value:'string',label:'Text'}, {value:'integer',label:'Ganze Zahl'}, {value:'number',label:'Zahl'}, {value:'boolean',label:'Ja / Nein'}, {value:'array',label:'Liste'}, {value:'object',label:'Gruppe von Feldern'}]; + const typeDefaults = {string:'', integer:0, number:0, boolean:false, array:[], object:{}}; + const limits = {string:[['minLength','Mindestlänge'],['maxLength','Höchstlänge']], number:[['minimum','Kleinster Wert'],['maximum','Größter Wert']], integer:[['minimum','Kleinster Wert'],['maximum','Größter Wert']], array:[['minItems','Mindestens so viele Einträge'],['maxItems','Höchstens so viele Einträge']]}; + + function propertyCard(name, schema, required, depth) { + const key = uid('schema-property'); + return `
Parameter${button('Parameter entfernen', 'schema-remove-property')}
${field(`${key}-name`, 'Parametername', name, {required:true, placeholder:'z. B. servers'})}${field(`${key}-required`, 'Eingabe erforderlich', required, {type:'checkbox'})}
${schemaNode(schema, depth + 1)}
`; + } + + function schemaBody(schema, type, key, depth) { + if (type === 'custom') return `
${dataEditor(`${key}-custom`, schema, {label:'Weitere Parameterregeln grafisch bearbeiten'})}
`; + const managed = new Set(['type','title','description','default','enum', ...(limits[type] || []).map(item => item[0])]); + if (type === 'object') ['properties','required','additionalProperties'].forEach(name => managed.add(name)); + if (type === 'array') ['items','uniqueItems'].forEach(name => managed.add(name)); + const extras = Object.fromEntries(Object.entries(schema).filter(([name]) => !managed.has(name))); + let specific = ''; + if (type === 'object') { + const props = schema.properties || {}; + const required = new Set(schema.required || []); + const additional = !own(schema, 'additionalProperties') ? 'unset' : typeof schema.additionalProperties === 'boolean' ? String(schema.additionalProperties) : 'schema'; + specific = `
${Object.entries(props).map(([name, rule]) => propertyCard(name, rule, required.has(name), depth)).join('')}
${button('Parameter hinzufügen', 'schema-add-property')}
${field(`${key}-additional`, 'Weitere Felder erlauben', additional, {type:'select', options:[{value:'unset',label:'Keine zusätzliche Einschränkung'}, {value:'false',label:'Nur die aufgeführten Felder'}, {value:'true',label:'Weitere Felder zulassen'}, {value:'schema',label:'Weitere Felder anhand eigener Regeln'}]})}
${dataEditor(`${key}-additional-schema`, object(schema.additionalProperties) ? schema.additionalProperties : {}, {label:'Regeln für weitere Felder'})}
`; + } else if (type === 'array') { + specific = `${field(`${key}-has-items`, 'Einträge der Liste definieren', own(schema, 'items'), {type:'checkbox'})}
${schemaNode(schema.items ?? {type:'string'}, depth + 1)}
${field(`${key}-unique`, 'Jeder Eintrag darf nur einmal vorkommen', schema.uniqueItems === true, {type:'checkbox'})}`; + } + const limitFields = (limits[type] || []).map(([name, label]) => field(`${key}-${name}`, label, schema[name] ?? '', {type:'number', ...(name.startsWith('min') || name.startsWith('max') ? (type === 'array' || type === 'string' ? {min:0} : {}) : {})}).replace(' +
${field(`${key}-title`, 'Anzeigename (optional)', schema.title || '')}${field(`${key}-description`, 'Beschreibung (optional)', schema.description || '')}${limitFields}
+ ${specific} +
${field(`${key}-has-default`, 'Vorgabewert anbieten', own(schema, 'default'), {type:'checkbox'})}
${dataEditor(`${key}-default`, own(schema, 'default') ? schema.default : copy(typeDefaults[type]), {label:'Vorgabewert'})}
+
${field(`${key}-has-enum`, 'Auswahl auf vorgegebene Werte beschränken', own(schema, 'enum'), {type:'checkbox'})}
${dataEditor(`${key}-enum`, schema.enum || [], {label:'Erlaubte Auswahlwerte', schema:{type:'array'}})}
+ Weitere Regeln${dataEditor(`${key}-extras`, extras, {label:'Weitere Parameterregeln', schema:{type:'object'}})} + `; + } + + function schemaNode(schema = {type:'string'}, depth = 0) { + const key = uid('schema-node'); + const supported = object(schema) && types.some(item => item.value === schema.type) && depth < 8; + const type = supported ? schema.type : 'custom'; + const options = [...types, {value:'custom', label:'Flexible / bestehende Regeln'}]; + return `
${field(`${key}-type`, 'Werttyp', type, {type:'select', options})}${schemaBody(schema, type, key, depth)}
`; + } + + function readSchemaNode(node, selectedType = null) { + const key = node.dataset.schemaKey; + const type = selectedType || fieldValue(node, `${key}-type`).value; + const body = direct(node, 'data-schema-body'); + if (type === 'custom') { + const result = readDataEditor(body, `${key}-custom`); + if (!object(result) && typeof result !== 'boolean') throw new Error('Parameterregeln müssen eine Gruppe von Feldern oder Ja / Nein sein.'); + return result; + } + const previous = original(body); + const extras = readDataEditor(body, `${key}-extras`); + if (!object(extras)) throw new Error('Weitere Parameterregeln müssen eine Gruppe von Feldern sein.'); + const result = {...extras, type}; + for (const name of ['title','description']) { + const value = fieldValue(body, `${key}-${name}`).value; + if (value || own(previous, name)) result[name] = value; + } + for (const [name, label] of limits[type] || []) { + const raw = fieldValue(body, `${key}-${name}`).value; + if (raw !== '') { + const value = Number(raw); + if (!Number.isFinite(value) || ((type === 'string' || type === 'array') && (!Number.isInteger(value) || value < 0))) throw new Error(`„${label}“ benötigt ${type === 'string' || type === 'array' ? 'eine nicht negative ganze Zahl' : 'eine gültige Zahl'}.`); + result[name] = value; + } + } + const pair = limits[type]; + if (pair && own(result, pair[0][0]) && own(result, pair[1][0]) && result[pair[0][0]] > result[pair[1][0]]) throw new Error('Der kleinste erlaubte Wert darf nicht größer als der größte erlaubte Wert sein.'); + if (type === 'object') { + const props = {}; + const required = []; + const previousProperties = previous.properties || {}; + for (const card of direct(body, 'data-schema-properties').children) { + const cardKey = card.dataset.propertyKey; + const name = fieldValue(card, `${cardKey}-name`).value.trim(); + if (!name) throw new Error('Geben Sie jedem Parameter einen Namen oder entfernen Sie die leere Parameterzeile.'); + if (own(props, name)) throw new Error(`Der Parametername „${name}“ ist doppelt vorhanden.`); + Object.defineProperty(props, name, {value:readSchemaNode(card.querySelector('[data-schema-node]')), enumerable:true, configurable:true, writable:true}); + if (fieldValue(card, `${cardKey}-required`).checked) required.push(name); + } + // Required keys without a property definition are valid schema and remain intact. + for (const name of previous.required || []) if (!own(previousProperties, name) && !required.includes(name)) required.push(name); + if (Object.keys(props).length || own(previous, 'properties')) result.properties = props; + if (required.length || own(previous, 'required')) result.required = required; + const additional = fieldValue(body, `${key}-additional`).value; + if (additional === 'true' || additional === 'false') result.additionalProperties = additional === 'true'; + else if (additional === 'schema') { + const rule = readDataEditor(body, `${key}-additional-schema`); + if (!object(rule) && typeof rule !== 'boolean') throw new Error('Regeln für weitere Felder müssen eine Gruppe oder Ja / Nein sein.'); + result.additionalProperties = rule; + } + } else if (type === 'array') { + if (fieldValue(body, `${key}-has-items`).checked) result.items = readSchemaNode(direct(body, 'data-schema-items').firstElementChild); + const unique = fieldValue(body, `${key}-unique`).checked; + if (unique || own(previous, 'uniqueItems')) result.uniqueItems = unique; + } + if (fieldValue(body, `${key}-has-default`).checked) result.default = readDataEditor(body, `${key}-default`); + if (fieldValue(body, `${key}-has-enum`).checked) { + const values = readDataEditor(body, `${key}-enum`); + if (!Array.isArray(values) || !values.length) throw new Error('Fügen Sie mindestens einen erlaubten Auswahlwert hinzu oder deaktivieren Sie die eingeschränkte Auswahl.'); + if (new Set(values.map(value => JSON.stringify(value))).size !== values.length) throw new Error('Erlaubte Auswahlwerte dürfen nicht doppelt vorkommen.'); + result.enum = values; + } + return result; + } + + function moduleSchema(schema = {type:'object', properties:{}, additionalProperties:false}) { + return `

Felder, Datentypen und erlaubte Werte festlegen. Diese Angaben werden im Ablaufeditor als Eingabefelder angezeigt.

${schemaNode(schema)}
`; + } + + function readModuleSchema(container, previousSchema) { + const root = rootIn(container, '[data-module-schema-editor]'); + if (!root) { + if (previousSchema !== undefined) return copy(previousSchema); + throw new Error('Der Parametereditor wurde nicht gefunden. Öffnen Sie das Formular erneut.'); + } + const result = readSchemaNode(direct(root, 'data-schema-node')); + if (!object(result)) throw new Error('Die Parameterdefinition des Moduls muss eine Gruppe von Regeln sein.'); + return result; + } + + function wireModuleSchema(container) { + const root = rootIn(container, '[data-module-schema-editor]'); + if (!root) return; + ensureDataEditors(root); + syncHiddenControls(root); + if (root.dataset.moduleFormsWired) return; + root.dataset.moduleFormsWired = 'true'; + root.addEventListener('click', event => { + const control = event.target.closest('[data-module-action]'); + if (!control || !root.contains(control)) return; + event.preventDefault(); + event.stopPropagation(); + clearError(root); + const node = control.closest('[data-schema-node]'); + if (control.dataset.moduleAction === 'schema-remove-property') control.closest('[data-schema-property]').remove(); + if (control.dataset.moduleAction === 'schema-add-property') { + const body = direct(node, 'data-schema-body'); + direct(body, 'data-schema-properties').insertAdjacentHTML('beforeend', propertyCard('', {type:'string'}, false, Number(node.dataset.schemaDepth))); + ensureDataEditors(root); + } + syncHiddenControls(root); + }); + root.addEventListener('change', event => { + const node = event.target.closest('[data-schema-node]'); + if (!node) return; + const key = node.dataset.schemaKey; + const body = direct(node, 'data-schema-body'); + const name = event.target.name; + clearError(root); + if (name === `${key}-type`) { + const nextType = event.target.value; + const cache = node._moduleSchemaCache || (node._moduleSchemaCache = new Map()); + if (nextType === 'custom' || node.dataset.currentType === 'custom') cache.delete(nextType); + let next; + if (!cache.has(nextType)) { + try { + const previousType = node.dataset.currentType; + const shared = nextType === 'custom' ? readSchemaNode(node, previousType) : readDataEditor(body, `${key}-${previousType === 'custom' ? 'custom' : 'extras'}`); + next = nextType === 'custom' ? shared : {...(object(shared) ? shared : {}), type:nextType}; + if (nextType === 'object') { + if (!own(next, 'properties')) next.properties = {}; + if (!own(next, 'additionalProperties')) next.additionalProperties = false; + } + if (nextType === 'array' && !own(next, 'items')) next.items = {type:'string'}; + if (object(next)) for (const attribute of ['title','description']) { + const input = fieldValue(body, `${key}-${attribute}`); + if (input?.value) next[attribute] = input.value; + } + } catch (error) { + event.target.value = node.dataset.currentType; + showError(root, error); + return; + } + } + cache.set(node.dataset.currentType, body); + body.remove(); + if (cache.has(nextType)) { node.append(cache.get(nextType)); cache.delete(nextType); } + else { + node.insertAdjacentHTML('beforeend', schemaBody(next, nextType, key, Number(node.dataset.schemaDepth))); + } + node.dataset.currentType = nextType; + ensureDataEditors(root); + } else { + const toggles = {'has-items':'data-schema-items', 'has-default':'data-schema-default', 'has-enum':'data-schema-enum'}; + for (const [suffix, attribute] of Object.entries(toggles)) if (name === `${key}-${suffix}`) { + const section = [...body.querySelectorAll(`[${attribute}]`)].find(item => item.closest('[data-schema-node]') === node); + if (section) section.hidden = !event.target.checked; + } + if (name === `${key}-additional`) direct(body, 'data-schema-additional').hidden = event.target.value !== 'schema'; + } + syncHiddenControls(root); + }); + } + + return {postinstall, readPostinstall, wirePostinstall, moduleSchema, readModuleSchema, wireModuleSchema}; +})(); + +function postinstallEditor(profile, modules, secrets) { return moduleFormTools.postinstall(profile, modules, secrets); } +function readPostinstallEditor(container, previousProfile = {}) { return moduleFormTools.readPostinstall(container, previousProfile); } +function wirePostinstallEditor(container, modules, secrets) { return moduleFormTools.wirePostinstall(container, modules, secrets); } +function moduleSchemaEditor(schema) { return moduleFormTools.moduleSchema(schema); } +function readModuleSchemaEditor(container, previousSchema) { return moduleFormTools.readModuleSchema(container, previousSchema); } +function wireModuleSchemaEditor(container) { return moduleFormTools.wireModuleSchema(container); } diff --git a/provisioner/static/profile-forms.js b/provisioner/static/profile-forms.js new file mode 100644 index 0000000..305440b --- /dev/null +++ b/provisioner/static/profile-forms.js @@ -0,0 +1,68 @@ +'use strict'; + +async function graphicalProfileForm(kind,existing=null) { + const p=existing||{}; + const install=kind==='installation'; + const [secrets,modules]=await Promise.all([api('/secrets'),install?Promise.resolve([]):api('/modules')]); + const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Einzelplatte':'PVE · Basiskonfiguration',hint:'Beim Speichern entsteht eine neue Profilversion.'})+ + dataEditor('profile-builds',arr(p.target_builds),{label:'Unterstützte Zielbuilds',schema:{type:'array',minItems:1,items:{type:'string',minLength:1}}})+ + (install?installationEditor(p.values||{},{prefix:'installation',secrets:arr(secrets)}):postinstallEditor(p,arr(modules).filter(m=>m.status==='published'),arr(secrets)))+ + field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'}); + showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern'),async data=>{ + const builds=readDataEditor(modal,'profile-builds'); + if(!builds.length||builds.some(build=>typeof build!=='string'||!build.trim()))throw new Error('Bitte mindestens einen tatsächlich geprüften Zielbuild angeben.'); + const configuration=install?{values:readInstallationEditor(modal,'installation',p.values||{}),steps:[]}:readPostinstallEditor(modal,p); + const body={name:data.get('name'),kind,target_builds:builds.map(build=>build.trim()),...configuration,reason:data.get('reason')||''}; + if(Object.hasOwn(p,'locked_fields'))body.locked_fields=p.locked_fields; + await api('/profiles',{method:'POST',body});closeModal();toast('Profilentwurf gespeichert.');await refresh(); + },install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL'); + wireDataEditors(modal); + if(install)wireInstallationEditor(modal,'installation');else wirePostinstallEditor(modal,arr(modules).filter(m=>m.status==='published'),arr(secrets)); +} +async function graphicalModuleForm(existing=null) { + const m=existing||{}; + const [catalog,modules]=await Promise.all([api('/modules/builtin'),api('/modules')]); + const templates=arr(catalog); + let source=m.source||''; + let schema=structuredClone(m.parameters_schema||{type:'object',properties:{},additionalProperties:false}); + let previousTemplateName=''; + const currentTemplate=templates.find(item=>item.id===m.id||item.source===m.source); + const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen'})+ + `

Modul auswählen

${field('module_template','Vorlage',currentTemplate?.id||'',{type:'select',full:true,options:[{value:'',label:source?'Vorhandenes Skript beibehalten':'Vorlage auswählen oder Skriptdatei laden'},...templates.map(item=>({value:item.id,label:item.name}))]})}

${source?'Skript übernommen.':'Noch kein Skript ausgewählt.'}

Skript ansehen
${esc(source)}
`+ + dataEditor('module-builds',arr(m.target_builds),{label:'Unterstützte Zielbuilds',schema:{type:'array',items:{type:'string'}}})+ + field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds??300,{type:'number',required:true,min:1,max:7200,full:true})+ + `
${moduleSchemaEditor(schema)}
`+ + `
${dataEditor('module-dependencies',arr(m.dependencies),{label:'Vorher benötigte Module',schema:{type:'array',items:{type:'string',enum:[...new Set([...arr(modules).map(item=>item.name),...templates.map(item=>item.name),...arr(m.dependencies)])]}}})}
`+ + field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe??false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit nachgewiesen wurde.'})+ + field('reason','Änderungsgrund','',{full:true}); + showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{ + if(!source)throw new Error('Bitte eine Vorlage oder Skriptdatei auswählen.'); + const builds=readDataEditor(modal,'module-builds'); + if(!builds.length||builds.some(build=>typeof build!=='string'||!build.trim()))throw new Error('Bitte mindestens einen geprüften Zielbuild angeben.'); + await api('/modules',{method:'POST',body:{name:data.get('name'),source,parameters_schema:readModuleSchemaEditor(modal,schema),dependencies:readDataEditor(modal,'module-dependencies'),target_builds:builds.map(build=>build.trim()),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh(); + },'VERSIONIERTE SKRIPTMODULE'); + wireDataEditors(modal); + wireModuleSchemaEditor(modal); + const updateSource=label=>{modal.querySelector('[data-source-status]').textContent=label;modal.querySelector('[data-source-preview]').textContent=source;}; + modal.querySelector('[name="module_template"]').addEventListener('change',event=>{ + const template=templates.find(item=>item.id===event.target.value); + if(!template)return; + source=template.source; + const nameInput=modal.querySelector('[name="name"]'); + if(!nameInput.value||nameInput.value===previousTemplateName)nameInput.value=template.name; + previousTemplateName=template.name; + schema=structuredClone(template.parameters_schema); + modal.querySelector('[data-schema-container]').innerHTML=moduleSchemaEditor(schema); + modal.querySelector('[data-dependencies-container]').innerHTML=dataEditor('module-dependencies',template.dependencies,{label:'Vorher benötigte Module',schema:{type:'array',items:{type:'string',enum:[...new Set([...arr(modules).map(item=>item.name),...templates.map(item=>item.name)])]}}}); + modal.querySelector('[name="timeout_seconds"]').value=template.timeout_seconds; + modal.querySelector('[name="retry_safe"]').checked=template.retry_safe; + wireModuleSchemaEditor(modal); + updateSource(`${template.name}: Vorlage übernommen.`); + }); + modal.querySelector('[data-module-file]').addEventListener('change',async event=>{ + const file=event.target.files[0]; + if(!file)return; + if(file.size>262144){toast('Die Skriptdatei darf höchstens 256 KiB groß sein.',true);event.target.value='';return;} + try{source=await file.text();modal.querySelector('[name="module_template"]').value='';updateSource(`${file.name}: Skript geladen.`);}catch{toast('Die Skriptdatei konnte nicht gelesen werden.',true);} + }); +} diff --git a/provisioner/templates/index.html b/provisioner/templates/index.html index 1f66b90..a19c9b3 100644 --- a/provisioner/templates/index.html +++ b/provisioner/templates/index.html @@ -6,6 +6,12 @@ Proxmox AIS · Provisionierung + + + + + + diff --git a/pyproject.toml b/pyproject.toml index aaabbd7..ac39453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "proxmox-ais-server" -version = "0.9.2" +version = "0.9.3" description = "Controlled Proxmox automated installation and resumable post-installation" readme = "README.md" requires-python = ">=3.12" @@ -15,6 +15,7 @@ dependencies = [ "jinja2==3.1.6", "python-multipart==0.0.32", "cryptography==50.0.1", + "passlib==1.7.4", "tomli-w==1.2.0", "jsonschema==4.26.0", "tzdata==2026.4", @@ -22,6 +23,7 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest==9.1.1", "httpx==0.28.1"] +browser = ["playwright==1.62.0"] [project.scripts] proxmox-ais = "provisioner.cli:main" diff --git a/tests/browser_forms.py b/tests/browser_forms.py new file mode 100644 index 0000000..10acc15 --- /dev/null +++ b/tests/browser_forms.py @@ -0,0 +1,302 @@ +"""Optional real-browser regression checks, outside default pytest collection. + +From the repository root after installing the normal development dependencies: + uv pip install --python .venv/Scripts/python.exe -e ".[dev,browser]" + .venv/Scripts/python.exe -m playwright install chromium + .venv/Scripts/python.exe tests/browser_forms.py + +On Linux/macOS use .venv/bin/python in place of .venv/Scripts/python.exe. +The suite starts isolated loopback servers; no deployment or real data is used. +Screenshots, fixture databases, and logs are in .cache/ui-browser/ (gitignored). +""" +from copy import deepcopy +import json + +from browser_support import local_browser + +PUBLICATION = {"test_evidence": "Synthetic browser acceptance fixture", "reason": "Browser fixture publication"} + + +def graphical_workflows(): + results = [] + with local_browser() as (page, api, ctx): + page.set_default_timeout(8000) + modal = page.locator("#modal") + + def route(name): + page.goto(ctx["base"] + "/#/" + name) + page.wait_for_selector("#main .page-header") + + def fill(name, value): + modal.locator(f'[name="{name}"]').fill(str(value)) + + def select(name, value): + modal.locator(f'[name="{name}"]').select_option(value) + + def builds(name): + editor = modal.locator(f'[data-editor="{name}"]') + editor.get_by_role("button", name="Eintrag hinzufügen", exact=True).click() + editor.locator("textarea[data-scalar-value]").last.fill("9.1-1") + + def submit(label="Entwurf speichern"): + modal.get_by_role("button", name=label, exact=True).click() + try: + modal.wait_for(state="hidden", timeout=12000) + except Exception: + print("FORM_FAILURE", modal.inner_text(), flush=True) + raise + + def no_code(): + modal.wait_for(state="visible") + assert modal.locator("textarea.code:not([readonly])").count() == 0 + assert "(JSON)" not in modal.inner_text() + + # New root password: no hash, command or source needed. + route("settings") + page.get_by_role("tab", name="Geheimnisse", exact=True).click() + page.locator('[data-action="create-secret"]').first.click() + fill("name", "Browser root password") + fill("value", "Browser-root-password-2026!") + assert modal.locator('[name="kind"]').input_value() == "root_password" + submit("Verschlüsselt speichern") + secret = next(s for s in api("/secrets") if s["name"] == "Browser root password") + results.append("Root password entered and saved through graphical UI") + + # Module template selection and schema preservation. + route("modules") + page.locator('[data-action="create-module"]').first.click() + select("module_template", "prerequisites") + builds("module-builds") + no_code() + submit() + prerequisites = next(m for m in api("/modules") if m["name"] == "Voraussetzungen") + catalog = {m["id"]: m for m in api("/modules/builtin")} + assert prerequisites["source"] == catalog["prerequisites"]["source"].strip() + assert prerequisites["parameters_schema"] == catalog["prerequisites"]["parameters_schema"], (prerequisites["parameters_schema"],catalog["prerequisites"]["parameters_schema"]) + api(f'/modules/{prerequisites["id"]}/publish', PUBLICATION) + template = catalog["final-verification"] + final_module = api("/modules", {key: deepcopy(template[key]) for key in ["name", "source", "parameters_schema", "dependencies", "timeout_seconds", "retry_safe"]} | {"target_builds": ["9.1-1"]}) + api(f'/modules/{final_module["id"]}/publish', PUBLICATION) + results.append("Template module created with unchanged source/schema") + + # Single-disk installation profile, entirely filled through normal controls. + route("installation") + page.locator('[data-action="create-profile"]').first.click() + fill("name", "Browser graphical installation") + builds("profile-builds") + fill("installation.global.mailto", "admin@example.net") + select("installation.root_secret_id", secret["id"]) + fill("installation.network.gateway", "192.0.2.1") + fill("installation.network.dns", "192.0.2.53") + select("installation.interface-key", "INTERFACE") + fill("installation.interface-value", "eno1") + assert modal.locator('[name="installation.disk.mode"]').input_value() == "all" + assert modal.locator('[name="installation.disk.zfs.raid"]').input_value() == "raid0" + no_code() + page.screenshot(path=str(ctx["artifacts"] / "installation-desktop.png"), full_page=True) + page.set_viewport_size({"width":390,"height":844}) + page.screenshot(path=str(ctx["artifacts"] / "installation-mobile.png"), full_page=True) + dimensions = modal.evaluate("node=>({width:node.clientWidth,scroll:node.scrollWidth,body:node.querySelector('#modal-body').scrollWidth})") + assert dimensions["scroll"] <= dimensions["width"] + 1, dimensions + page.set_viewport_size({"width":1440,"height":1000}) + submit() + install = next(p for p in api("/profiles") if p["name"] == "Browser graphical installation") + assert install["values"]["disk_setup"] == {"filesystem":"zfs", "selection":"all", "zfs":{"raid":"raid0"}} + api(f'/profiles/{install["id"]}/publish', PUBLICATION) + results.append("Single-disk ZFS RAID0 installation saved without filter/code; 390px layout fits") + + # Postinstallation step creation, ordering, parameter boolean and integer zero. + route("postinstall") + page.locator('[data-action="create-profile"]').first.click() + fill("name", "Browser graphical postinstall") + builds("profile-builds") + modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click() + first = modal.locator('[data-postinstall-step]').nth(0) + first.locator('select[name$="-module"]').select_option(prerequisites["id"]) + modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click() + second = modal.locator('[data-postinstall-step]').nth(1) + second.locator('select[name$="-module"]').select_option(final_module["id"]) + second.locator('[data-new-property]').select_option("require_time_sync") + second.get_by_role("button", name="Feld hinzufügen", exact=True).click() + checkbox = second.locator('[data-scalar-value][type="checkbox"]') + checkbox.uncheck() + second.get_by_role("button", name="↑ Nach oben", exact=True).click() + modal.locator('[data-postinstall-step]').nth(0).get_by_role("button", name="↓ Nach unten", exact=True).click() + fill("postinstall-reboot-budget", 0) + no_code() + page.screenshot(path=str(ctx["artifacts"] / "postinstall-desktop.png"), full_page=True) + submit() + postinstall = next(p for p in api("/profiles") if p["name"] == "Browser graphical postinstall") + assert [s["module_id"] for s in postinstall["steps"]] == [prerequisites["id"],final_module["id"]] + assert postinstall["steps"][1]["parameters"] == {"require_time_sync":False} + assert postinstall["reboot_budget"] == 0 + api(f'/profiles/{postinstall["id"]}/publish', PUBLICATION) + results.append("Postinstall step add/reorder/boolean false and zero restart budget preserved") + + # A complete host preview proves controls generated backend-supported configuration. + group = api("/groups", {"name":"browser-lab", "site":"lab", "valid_hours":1}) + iso = api("/iso-records", {"name":"Synthetic browser ISO", "build":"9.1-1", "sha256":"1"*64,"assistant_version":"test-only","fingerprint":"2"*64,"group_id":group["id"],"native_token_support":True,"test_status":"passed","test_evidence":"Synthetic browser fixture only"}) + route("hosts") + page.locator('[data-action="create-host"]').first.click() + fill("fqdn", "browser.lab.example.net") + fill("site", "lab") + fill("management_ip", "192.0.2.10/24") + fill("identity_value", "BROWSER-SERVER-01") + modal.get_by_role("button", name="Kennung hinzufügen", exact=True).click() + identity = modal.locator('[data-identity-row]').nth(1) + identity.locator('[name="identity_kind"]').select_option("mac") + identity.locator('[name="identity_value"]').fill("02:00:00:00:00:01") + select("installation_profile_id", install["id"]) + select("postinstall_profile_id", postinstall["id"]) + select("iso_id", iso["id"]) + no_code() + submit("Server anlegen") + host = next(h for h in api("/hosts") if h["fqdn"] == "browser.lab.example.net") + assert len(host["identities"]) == 2 and host["overrides"] == {} + preview = api(f'/hosts/{host["id"]}/preview') + assert preview["resolved"]["disk_setup"] == install["values"]["disk_setup"] + assert preview["resolved"]["global"]["fqdn"] == host["fqdn"] + assert preview["resolved"]["network"]["cidr"] == "192.0.2.10/24" + route("hosts/" + host["id"]) + page.locator('[data-action="preview-host"]').click() + no_code() + assert "raid0" in modal.inner_text().lower(), modal.inner_text() + modal.get_by_role("button", name="Dialog schließen").click() + page.locator('[data-action="edit-host"]').click() + submit("Änderungen speichern") + assert api(f'/hosts/{host["id"]}')["version"] == host["version"] + results.append("Host identity rows, assignments, no-change edit and full resolved preview passed") + + # Multiple host entry uses repeatable rows, retains optional management IP. + route("hosts") + page.locator('[data-action="import-hosts"]').click() + fill("site", "batch") + rows = modal.locator('[data-import-host]') + rows.nth(0).locator('[name="import_fqdn"]').fill("batch1.lab.example.net") + rows.nth(0).locator('[name="identity_value"]').fill("BROWSER-BATCH-1") + modal.get_by_role("button", name="Weiteren Server hinzufügen", exact=True).click() + rows.nth(1).locator('[name="import_fqdn"]').fill("batch2.lab.example.net") + rows.nth(1).locator('[name="identity_value"]').fill("BROWSER-BATCH-2") + no_code() + submit("Server anlegen") + batch = [h for h in api("/hosts") if h["site"] == "batch"] + assert len(batch) == 2 and all(h["management_ip"] is None for h in batch) + results.append("Multiple host entry saved two rows with optional management IP") + (ctx["artifacts"] / "acceptance-results.json").write_text(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2),encoding="utf-8") + print(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2), flush=True) + + +PUB = PUBLICATION + +def normalized_schema(value): + if isinstance(value,dict): + return {key:sorted(item) if key=='required' and isinstance(item,list) else normalized_schema(item) for key,item in value.items()} + if isinstance(value,list): + return [normalized_schema(item) for item in value] + return value + +def preservation_checks(): + with local_browser() as (page,api,ctx): + page.set_default_timeout(8000) + modal=page.locator("#modal") + results=[] + def route(path): + page.goto(ctx["base"]+"/#/"+path) + page.wait_for_selector("#main .page-header") + def submit(): + modal.get_by_role("button",name="Entwurf speichern",exact=True).click() + try: modal.wait_for(state="hidden") + except Exception: + print(modal.inner_text(),flush=True) + raise + secret=api("/secrets",{"name":"fixture","value":"$6$fixture$"+"A"*86}) + values={ + "global":{"keyboard":"de-ch","country":"ch","timezone":"Europe/Zurich","mailto":"admin@example.net","fqdn":"template.example.net","root-ssh-keys":[],"reboot-on-error":False}, + "network":{"source":"from-answer","cidr":"192.0.2.10/24","gateway":"192.0.2.1","dns":"192.0.2.53","filter":{"INTERFACE":"eno1","CUSTOM_MARKER":"firmware-slot"}}, + "disk_setup":{"filesystem":"zfs","filter":{"ID_SERIAL":"DISK-*"},"filter_match":"any","expected_count":2,"expected_serials":["DISK-A","DISK-B"],"inventory_evidence":"Inventory fixture reference","zfs":{"raid":"raid1","ashift":12,"arc-max":2048,"copies":2,"hdsize":123.5,"checksum":"sha256","compress":"off"}}, + "root_secret_id":secret["id"], + } + profile=api("/profiles",{"name":"Detailed existing profile","kind":"installation","values":values,"target_builds":["9.1-1"],"locked_fields":["disk_setup","network.gateway"]}) + route("installation") + page.locator(f'[data-action="version-profile"][data-id="{profile["id"]}"]').click() + submit() + versions=sorted([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) + assert versions[-1]["values"]==values,(versions[-1]["values"],values) + assert versions[-1]["locked_fields"]==profile["locked_fields"] + results.append("Detailed ZFS/network/global config and custom locked fields roundtrip unchanged") + + # Switching filesystem removes incompatible settings and retains a zero swap. + page.locator(f'[data-action="version-profile"][data-id="{versions[-1]["id"]}"]').click() + modal.locator('[name="installation.disk.filesystem"]').select_option("ext4") + serial=modal.locator('[data-editor="installation.disk.expected_serials"]') + serial.locator('[data-edit-action="remove"]').last.click() + modal.locator('[name="installation.disk.expected_count"]').fill("1") + modal.locator('[name="installation.disk.lvm.swapsize"]').fill("0") + submit() + latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) + assert "zfs" not in latest["values"]["disk_setup"] + assert latest["values"]["disk_setup"]["lvm"]["swapsize"]==0 + page.locator(f'[data-action="version-profile"][data-id="{latest["id"]}"]').click() + modal.locator('[name="installation.disk.filesystem"]').select_option("zfs") + modal.locator('[name="installation.disk.mode"]').select_option("all") + modal.locator('[name="installation.disk.zfs.raid"]').select_option("raid0") + submit() + latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) + disks=latest["values"]["disk_setup"] + assert disks["selection"]=="all" and disks["zfs"]["raid"]=="raid0" + assert not set(disks).intersection({"lvm","filter","filter_match","expected_count","expected_serials"}) + results.append("RAID1 to ext4 to automatic RAID0 strips incompatible fields and preserves numeric zero") + + # Sparse host override editing must not replace inherited configuration. + overrides={"global":{"reboot-on-error":False},"network":{"dns":"192.0.2.54"},"root_secret_id":secret["id"]} + host=api("/hosts",{"fqdn":"preserve.example.net","site":"lab","identities":[{"kind":"serial","value":"PRESERVE-HOST"}],"overrides":overrides}) + route("hosts/"+host["id"]) + page.locator('[data-action="edit-host"]').click() + modal.locator('[name="site"]').fill("new-lab") + modal.get_by_role("button",name="Änderungen speichern",exact=True).click() + modal.wait_for(state="hidden") + assert api('/hosts/'+host['id'])['overrides']==overrides + results.append("Sparse host overrides preserve inherited values and explicit false") + + # Nested catalog schemas and nonstandard module fields preserve their rules. + catalog={m["id"]:m for m in api("/modules/builtin")} + modules=[] + for key in ["prerequisites","ssh"]: + template=catalog[key] + body={k:deepcopy(template[k]) for k in ["name","source","parameters_schema","dependencies","timeout_seconds","retry_safe"]} + body['target_builds']=['9.1-1'] + module=api('/modules',body) + api('/modules/'+module['id']+'/publish',PUB) + modules.append(module) + route('modules') + page.locator(f'[data-action="version-module"][data-id="{modules[1]["id"]}"]').click() + submit() + ssh_versions=[m for m in api('/modules') if m['name']==modules[1]['name']] + assert normalized_schema(max(ssh_versions,key=lambda m:m['version'])['parameters_schema'])==normalized_schema(modules[1]['parameters_schema']) + parameters={"users":[{"name":"root","authorized_keys":["ssh-ed25519 AAAATEST original"]}]} + steps=[{"id":"fixed-first","module_id":modules[0]['id'],"parameters":{},"secret_refs":{},"required":True},{"id":"fixed-ssh","module_id":modules[1]['id'],"parameters":parameters,"secret_refs":{"TEST_KEY":secret['id']},"required":False}] + post=api('/profiles',{"name":"Nested existing post","kind":"postinstall","target_builds":["9.1-1"],"steps":steps,"values":{"custom":{"enabled":False,"zero":0,"empty":[],"missing":None}},"reboot_budget":0}) + route('postinstall') + page.locator(f'[data-action="version-profile"][data-id="{post["id"]}"]').click() + second=modal.locator('[data-postinstall-step]').nth(1) + keys=second.locator('[data-entry]:has(> .data-key-label > [data-entry-key][value="authorized_keys"]) > .data-entry-value > [data-value-node]') + keys.locator(':scope > .data-node-content > .data-add > button').click() + keys.locator('textarea[data-scalar-value]').last.fill("ssh-ed25519 AAAATEST second") + page.set_viewport_size({"width":390,"height":844}) + second.scroll_into_view_if_needed() + page.screenshot(path=str(ctx["artifacts"]/'postinstall-nested-mobile.png'),full_page=True) + size=modal.evaluate('n=>({width:n.clientWidth,scroll:n.scrollWidth})') + assert size['scroll']<=size['width']+1,size + page.set_viewport_size({"width":1440,"height":1000}) + submit() + updated=max([p for p in api('/profiles') if p['name']==post['name']],key=lambda p:p['version']) + assert updated['values']==post['values'] + expected=deepcopy(steps) + expected[1]['parameters']['users'][0]['authorized_keys'].append('ssh-ed25519 AAAATEST second') + assert updated['steps']==expected,(updated['steps'],expected) + results.append('Nested SSH key add preserves fixed step IDs, references, false required flag, custom null/empty/false/zero values; mobile layout fits') + print(json.dumps({'passed':results,'errors':ctx['errors']},indent=2),flush=True) + +if __name__ == "__main__": + graphical_workflows() + preservation_checks() diff --git a/tests/browser_support.py b/tests/browser_support.py new file mode 100644 index 0000000..9a30a73 --- /dev/null +++ b/tests/browser_support.py @@ -0,0 +1,98 @@ +"""Isolated localhost application and Chromium support for browser_forms.py. + +Only generated test credentials/data are used. Fixture databases, server logs, +and screenshots are written below the repository's ignored .cache/ui-browser. +The server process and browser are always terminated after each scenario group. +""" +from contextlib import contextmanager +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time + +import httpx +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +TEST_PASSWORD = "Browser-fixture-admin-2026!" + + +@contextmanager +def local_browser(viewport=None): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + base = f"http://127.0.0.1:{port}" + artifacts = ROOT / ".cache" / "ui-browser" + artifacts.mkdir(parents=True, exist_ok=True) + tempdir = Path(tempfile.mkdtemp(prefix="fixture-", dir=artifacts)) + env = { + **os.environ, + "PYTHONPATH": str(ROOT), + "DATA_DIR": str(tempdir / "data"), + "MASTER_KEY_FILE": str(tempdir / "keys" / "master.key"), + "PUBLIC_URL": base, + "SECURE_COOKIES": "false", + "BOOTSTRAP_USERNAME": "admin", + "BOOTSTRAP_PASSWORD": TEST_PASSWORD, + "TESTING": "true", + "FOUR_EYES": "false", + } + log = (tempdir / "server.log").open("w", encoding="utf-8") + server = subprocess.Popen( + [sys.executable, "-m", "provisioner.cli", "serve", "--host", "127.0.0.1", "--port", str(port)], + cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + try: + for _ in range(80): + if server.poll() is not None: + raise RuntimeError((tempdir / "server.log").read_text(encoding="utf-8")) + try: + if httpx.get(base + "/health/ready", timeout=1).status_code == 200: + break + except httpx.HTTPError: + pass + time.sleep(0.15) + else: + raise TimeoutError("Local application did not become ready") + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context(viewport=viewport or {"width": 1440, "height": 1000}) + page = context.new_page() + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto(base + "/") + page.get_by_label("Benutzername").fill("admin") + page.get_by_label("Passwort", exact=True).fill(TEST_PASSWORD) + page.get_by_role("button", name="Anmelden").click() + page.wait_for_selector("#main .page-header") + csrf = context.request.get(base + "/api/v1/me").json()["csrf_token"] + + def api(path, body=None): + response = context.request.get(base + "/api/v1" + path) if body is None else context.request.post( + base + "/api/v1" + path, data=body, headers={"X-CSRF-Token": csrf}) + assert response.ok, f"{path}: {response.status} {response.text()}" + return response.json() + + yield page, api, {"base": base, "artifacts": artifacts, "fixture": tempdir, "errors": errors} + assert not errors, errors + context.close() + browser.close() + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + log.close() + + +if __name__ == "__main__": + with local_browser() as (page, api, context): + print(json.dumps({"title": page.title(), "url": page.url, "me": api("/me")["username"], "errors": context["errors"]})) diff --git a/tests/container_smoke.py b/tests/container_smoke.py index 6a39f3b..7efbd22 100644 --- a/tests/container_smoke.py +++ b/tests/container_smoke.py @@ -7,6 +7,7 @@ from http.cookiejar import CookieJar import json import os from pathlib import Path +import re import secrets import socket import ssl @@ -77,13 +78,18 @@ def main(): form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode() with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response: assert response.status == 200 - assert b"/static/app.js" in response.read() + page = response.read() + assert b"/static/app.js" in page with request("/api/v1/me") as response: csrf = json.load(response)["csrf_token"] with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response: host_id = json.load(response)["id"] - with opener.open(base+"/static/app.js",timeout=5) as response: - assert response.status == 200 and len(response.read())>1000 + assets = set(re.findall(rb'(?:src|href)="(/static/[^\"]+)"', page)) + assert b"/static/installation-form.js" in assets + assert b"/static/module-forms.js" in assets + for asset in assets: + with opener.open(base+asset.decode(),timeout=5) as response: + assert response.status == 200 and response.read(), asset process.terminate() process.wait(timeout=15) process = start() diff --git a/tests/test_secret_forms.py b/tests/test_secret_forms.py new file mode 100644 index 0000000..c0d27e1 --- /dev/null +++ b/tests/test_secret_forms.py @@ -0,0 +1,76 @@ +"""Root passwords entered through the UI become encrypted installer hashes.""" +import json + +from passlib.hash import sha512_crypt +import pytest + +from provisioner.security import Security +from test_acceptance import PASSWORD, environment, login, post + + +def test_root_password_secret_is_hashed_encrypted_and_never_returned(environment): + app, client, csrf = environment + password = " Installer-password-2026! " + created = post(client, "/api/v1/secrets", { + "name": "Installer root password", "kind": "root_password", "value": password, + }, csrf) + assert set(created) == {"id", "name"} + with app.state.db.connection() as connection: + row = connection.execute("SELECT * FROM secrets WHERE id=?", (created["id"],)).fetchone() + audit_data = json.dumps([dict(entry) for entry in connection.execute("SELECT * FROM audit")]) + stored = app.state.security.decrypt(row["ciphertext"]) + assert stored.startswith("$6$rounds=656000$") + assert sha512_crypt.verify(password, stored) + assert not sha512_crypt.verify(password.strip(), stored) + assert password not in row["ciphertext"] and stored not in row["ciphertext"] + public_data = json.dumps(created) + client.get("/api/v1/secrets").text + audit_data + assert password not in public_data and stored not in public_data + + +@pytest.mark.parametrize("password", ["short", "x" * 1025, "Valid-password\x00invalid"]) +def test_root_password_secret_rejects_invalid_password_without_echo(environment, password): + app, client, csrf = environment + response = client.post("/api/v1/secrets", json={ + "name": "Invalid root password", "kind": "root_password", "value": password, + }, headers=csrf) + assert response.status_code == 422 + assert password not in response.text + with app.state.db.connection() as connection: + assert connection.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] == 0 + + +def test_raw_secret_default_preserves_existing_behavior(environment): + app, client, csrf = environment + created = post(client, "/api/v1/secrets", {"name": "Raw value", "value": " unchanged-value "}, csrf) + with app.state.db.connection() as connection: + row = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (created["id"],)).fetchone() + assert app.state.security.decrypt(row["ciphertext"]) == "unchanged-value" + assert client.post("/api/v1/secrets", json={"name": "Empty raw value", "value": " "}, headers=csrf).status_code == 422 + + +@pytest.mark.parametrize("role, metadata_status", [("author", 200), ("operator", 200), ("reader", 403)]) +def test_secret_metadata_roles_cannot_create_or_read_secret_values(environment, role, metadata_status): + app, client, csrf = environment + created = post(client, "/api/v1/secrets", {"name": "Private root reference", "value": "private-value"}, csrf) + post(client, "/api/v1/users", {"username": role, "password": PASSWORD, "role": role}, csrf) + user_csrf = login(client, username=role) + response = client.get("/api/v1/secrets") + assert response.status_code == metadata_status + if metadata_status == 200: + assert response.json()[0]["id"] == created["id"] + assert set(response.json()[0]) == {"id", "name", "created_at"} + assert "private-value" not in response.text + denied = client.post("/api/v1/secrets", json={ + "name": "Denied password", "kind": "root_password", "value": "Forbidden-password-2026!", + }, headers=user_csrf) + assert denied.status_code == 403 + assert "Forbidden-password" not in denied.text + + +def test_installer_password_hash_uses_random_salt(): + password = "Installer-password-2026!" + first = Security.hash_installer_password(password) + second = Security.hash_installer_password(password) + assert first != second + assert sha512_crypt.verify(password, first) + assert sha512_crypt.verify(password, second)