Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b979e6243 |
@@ -62,7 +62,10 @@ jobs:
|
|||||||
node-version: "24"
|
node-version: "24"
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
- name: Check JavaScript syntax
|
- 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:
|
container-policy:
|
||||||
runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }}
|
runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }}
|
||||||
|
|||||||
@@ -116,14 +116,20 @@ Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deploy
|
|||||||
|
|
||||||
## Erster Installationslauf
|
## 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`,
|
1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`,
|
||||||
`author`, `admin` und `developer`.
|
`author`, `admin` und `developer`.
|
||||||
2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten
|
2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten
|
||||||
Token sicher speichern. Er ist Bestandteil des Installationsmediums.
|
Token sicher speichern. Er ist Bestandteil des Installationsmediums.
|
||||||
3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen.
|
3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen.
|
||||||
Das Medium erst nach bestandenem Labortest freigeben.
|
Das Medium erst nach bestandenem Labortest freigeben.
|
||||||
4. Root-Zugang als Geheimnis speichern, Installationsprofil und
|
4. Root-Zugang als Geheimnis speichern; ein eingegebenes Root-Passwort wird
|
||||||
Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und
|
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
|
`verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig
|
||||||
muss eine andere Person als der Autor veröffentlichen.
|
muss eine andere Person als der Autor veröffentlichen.
|
||||||
5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen
|
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.
|
`.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden.
|
||||||
Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen.
|
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
|
Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte
|
||||||
Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den
|
Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den
|
||||||
Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich
|
Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich
|
||||||
|
|||||||
@@ -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
|
In der Weboberfläche werden dafür **ZFS**, **RAID0 / Einzelplatte** und die
|
||||||
im Profilformular ein. Die übrigen Profilparameter werden dabei beibehalten.
|
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;
|
`selection: "all"` verwendet alle vom Proxmox-Installer erkannten Zielplatten;
|
||||||
es prüft nicht, ob tatsächlich nur eine Platte vorhanden ist. Die native Antwort
|
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
|
nutzt dafür `filter.DEVTYPE = "disk"`. CD-ROM- und ISO9660-Installationsmedien
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Proxmox AIS",
|
"title": "Proxmox AIS",
|
||||||
"description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.",
|
"description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.",
|
||||||
"version": "0.9.2"
|
"version": "0.9.3"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/health/live": {
|
"/health/live": {
|
||||||
@@ -2371,6 +2371,15 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Name"
|
"title": "Name"
|
||||||
},
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"value",
|
||||||
|
"root_password"
|
||||||
|
],
|
||||||
|
"title": "Kind",
|
||||||
|
"default": "value"
|
||||||
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 16384,
|
"maxLength": 16384,
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Proxmox AIS: controlled installation and post-installation provisioning."""
|
"""Proxmox AIS: controlled installation and post-installation provisioning."""
|
||||||
|
|
||||||
__version__ = "0.9.2"
|
__version__ = "0.9.3"
|
||||||
|
|||||||
+3
-2
@@ -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)
|
return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection)
|
||||||
|
|
||||||
@app.get("/api/v1/secrets")
|
@app.get("/api/v1/secrets")
|
||||||
def secrets_list(user=Depends(roles())):
|
def secrets_list(user=Depends(roles("author","operator"))):
|
||||||
with db.connection() as connection:
|
with db.connection() as connection:
|
||||||
return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")]
|
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)
|
@app.post("/api/v1/secrets",status_code=201)
|
||||||
def create_secret(payload:SecretCreate,user=Depends(roles())):
|
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:
|
with db.connection(write=True) as connection:
|
||||||
secret_id = new_id("secret")
|
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)
|
audit(connection,user["id"],"secret.created",secret_id)
|
||||||
return {"id":secret_id,"name":payload.name}
|
return {"id":secret_id,"name":payload.name}
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -1,9 +1,9 @@
|
|||||||
from typing import Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from ipaddress import ip_interface
|
from ipaddress import ip_interface
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationInfo, field_validator
|
||||||
|
|
||||||
|
|
||||||
class Model(BaseModel):
|
class Model(BaseModel):
|
||||||
@@ -156,7 +156,19 @@ class UserCreate(Model):
|
|||||||
|
|
||||||
class SecretCreate(Model):
|
class SecretCreate(Model):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
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):
|
class Enroll(Model):
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
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):
|
def canonical(value):
|
||||||
@@ -43,6 +50,12 @@ class Security:
|
|||||||
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
|
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
|
||||||
return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode()
|
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
|
@staticmethod
|
||||||
def verify_password(password, stored):
|
def verify_password(password, stored):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+21
-65
@@ -128,12 +128,12 @@ function dashboard(data) {
|
|||||||
}
|
}
|
||||||
function hostsPage(hosts, discoveries=[]) {
|
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()))));
|
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')+`<section class="card filterable">${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):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
|
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')+`<section class="card filterable">${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):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
|
||||||
}
|
}
|
||||||
function hostPage(host) {
|
function hostPage(host) {
|
||||||
const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities);
|
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'}`,`<a class="button" href="#/hosts">${svg('back')}Inventar</a>${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+
|
return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`<a class="button" href="#/hosts">${svg('back')}Inventar</a>${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+
|
||||||
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'–')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'–')}</dd><dt>Standort</dt><dd>${esc(host.site||'–')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||'–'}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${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')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.discovered_data))}</pre></div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.overrides))}</pre></div></section></div></div>`;
|
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'–')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'–')}</dd><dt>Standort</dt><dd>${esc(host.site||'–')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||'–'}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${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')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content">${dataView(host.discovered_data)}</div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content">${dataView(host.overrides)}</div></section></div></div>`;
|
||||||
}
|
}
|
||||||
function profilesPage(profiles, kind) {
|
function profilesPage(profiles, kind) {
|
||||||
const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===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 resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested;
|
||||||
const actions=`<a class="button" href="#/runs">${svg('back')}Alle Läufe</a>${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)}"`):''}`;
|
const actions=`<a class="button" href="#/runs">${svg('back')}Alle Läufe</a>${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='';
|
let content='';
|
||||||
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '–')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}</summary><pre class="code-block light">${esc(json(s.verification))}</pre></details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary><pre class="code-block light">${esc(json(s.checkpoint))}</pre></details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
|
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '–')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Prüfergebnis${s.status==='failed'?' / Fehlerursache':''}</summary>${dataView(s.verification)}</details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary>${dataView(s.checkpoint)}</details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`: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==='events') content=eventList(events,'Noch keine Laufereignisse');
|
||||||
if(state.runTab==='logs') content=logs.length?`<div class="card-content"><pre class="code-block">${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'))}</pre></div>`:empty('Noch keine Protokolldaten','Der Runner übermittelt redigierte Protokolle während der Ausführung.','code');
|
if(state.runTab==='logs') content=logs.length?`<div class="card-content"><pre class="code-block">${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'))}</pre></div>`: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')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):'–'}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'–')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p><pre class="code-block light">${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}</pre></div></section></div></div>`;
|
return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):'–'}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'–')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p>${dataView(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id})}</div></section></div></div>`;
|
||||||
}
|
}
|
||||||
function mediaPage(records, groups=[]) {
|
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')+
|
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={}) {
|
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)}"`:''}`;
|
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;
|
let input;
|
||||||
if(options.type==='textarea'||options.type==='json') input=`<textarea ${attrs} class="${options.type==='json'?'code':''}"${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
|
if(options.type==='textarea') input=`<textarea ${attrs}${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
|
||||||
else if(options.type==='select') input=`<select ${attrs}>${(options.options||[]).map(o=>`<option value="${esc(o.value)}"${String(o.value)===String(value)?' selected':''}>${esc(o.label)}</option>`).join('')}</select>`;
|
else if(options.type==='select') input=`<select ${attrs}>${(options.options||[]).map(o=>`<option value="${esc(o.value)}"${String(o.value)===String(value)?' selected':''}>${esc(o.label)}</option>`).join('')}</select>`;
|
||||||
else if(options.type==='checkbox') return `<label class="checkbox${options.full?' full':''}"><input type="checkbox" ${attrs}${value?' checked':''}><span>${esc(label)}${options.hint?`<br><small>${esc(options.hint)}</small>`:''}</span></label>`;
|
else if(options.type==='checkbox') return `<label class="checkbox${options.full?' full':''}"><input type="checkbox" ${attrs}${value?' checked':''}><span>${esc(label)}${options.hint?`<br><small>${esc(options.hint)}</small>`:''}</span></label>`;
|
||||||
else input=`<input type="${esc(options.type||'text')}" ${attrs} value="${esc(value)}">`;
|
else input=`<input type="${esc(options.type||'text')}" ${attrs} value="${esc(value)}">`;
|
||||||
@@ -189,24 +189,10 @@ function field(name,label,value='',options={}) {
|
|||||||
function form(fields,label='Speichern',intro='') {
|
function form(fields,label='Speichern',intro='') {
|
||||||
return `${intro}<form id="modal-form"><div class="form-grid">${fields}</div><div class="form-error alert alert-danger" role="alert"></div><div class="form-actions"><button type="button" class="button" data-action="close-modal">Abbrechen</button><button type="submit" class="button primary">${esc(label)}</button></div></form>`;
|
return `${intro}<form id="modal-form"><div class="form-grid">${fields}</div><div class="form-error alert alert-danger" role="alert"></div><div class="form-actions"><button type="button" class="button" data-action="close-modal">Abbrechen</button><button type="submit" class="button primary">${esc(label)}</button></div></form>`;
|
||||||
}
|
}
|
||||||
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 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}`:''}`}))];
|
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) {
|
async function hostForm(existing=null, discovery=null) { return graphicalHostForm(existing,discovery); }
|
||||||
const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
|
async function hostImportForm() { return graphicalHostImport(); }
|
||||||
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 moduleCatalog() {
|
async function moduleCatalog() {
|
||||||
const catalog=arr(await api('/modules/builtin'));
|
const catalog=arr(await api('/modules/builtin'));
|
||||||
showModal('Basismodul als Entwurf übernehmen',`<div class="alert alert-info">Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.</div>${catalog.map(m=>`<div class="run-step"><span class="step-number">${svg('code')}</span><div class="run-step-copy"><strong>${esc(m.name)}</strong><p>${esc(m.description)}</p></div><button type="button" class="button small" data-action="use-module-template" data-id="${esc(m.id)}">Übernehmen</button></div>`).join('')}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULVORLAGEN');
|
showModal('Basismodul als Entwurf übernehmen',`<div class="alert alert-info">Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.</div>${catalog.map(m=>`<div class="run-step"><span class="step-number">${svg('code')}</span><div class="run-step-copy"><strong>${esc(m.name)}</strong><p>${esc(m.description)}</p></div><button type="button" class="button small" data-action="use-module-template" data-id="${esc(m.id)}">Übernehmen</button></div>`).join('')}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULVORLAGEN');
|
||||||
@@ -215,7 +201,7 @@ async function moduleCatalog() {
|
|||||||
function previewContent(preview) {
|
function previewContent(preview) {
|
||||||
const p=preview||{}, warnings=arr(p.warnings);
|
const p=preview||{}, warnings=arr(p.warnings);
|
||||||
const network=p.resolved?.network||{};
|
const network=p.resolved?.network||{};
|
||||||
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'–')} · ${esc(p.iso?.build||'–')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'–')}<br>Gateway ${esc(network.gateway||'–')} · DNS ${esc(network.dns||'–')}<br><span class="mono">${esc(JSON.stringify(network.filter||{}))}</span></dd><dt>Zieldatenträger</dt><dd><pre class="code-block light">${esc(json(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {}))}</pre></dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||'–'}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'–')}`).join('<br>')||'–'}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'–')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary><pre class="code-block light">${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}</pre></details>`;
|
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'–')} · ${esc(p.iso?.build||'–')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'–')}<br>Gateway ${esc(network.gateway||'–')} · DNS ${esc(network.dns||'–')}<br>${dataView(network.filter||{})}</dd><dt>Zieldatenträger</dt><dd>${dataView(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {})}</dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||'–'}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'–')}`).join('<br>')||'–'}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'–')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary>${dataView({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps})}</details>`;
|
||||||
}
|
}
|
||||||
async function approveHost(id, previewOnly=false) {
|
async function approveHost(id, previewOnly=false) {
|
||||||
const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]);
|
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();
|
closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh();
|
||||||
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
|
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
|
||||||
}
|
}
|
||||||
const installationExample = {
|
async function profileForm(kind, existing=null) { return graphicalProfileForm(kind,existing); }
|
||||||
global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'},
|
async function moduleForm(existing=null) { return graphicalModuleForm(existing); }
|
||||||
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?`<div class="alert alert-info"><strong>Server mit einer Festplatte</strong><p>Die Vorlage verwendet alle vom Installer erkannten Zielplatten. Für Server mit genau einer Platte sind damit weder Seriennummer noch Gerätename nötig.</p>${actionButton('ZFS (RAID0) für Einzelplatte','use-single-disk-zfs','disc')}</div>`:'';
|
|
||||||
if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`<div class="alert alert-info">Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: <code>${esc(m.id)}</code>`).join('<br>'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}</div>`;}
|
|
||||||
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 publishObject(type, id) {
|
async function publishObject(type, id) {
|
||||||
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
|
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
|
||||||
const intro=`<div class="alert alert-info">${esc(item.name)} · Version ${esc(item.version)}<br>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.</div>`;
|
const intro=`<div class="alert alert-info">${esc(item.name)} · Version ${esc(item.version)}<br>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.</div>`;
|
||||||
@@ -260,10 +224,10 @@ async function publishObject(type, id) {
|
|||||||
},'VERÖFFENTLICHUNG');
|
},'VERÖFFENTLICHUNG');
|
||||||
}
|
}
|
||||||
function inspectProfile(p) {
|
function inspectProfile(p) {
|
||||||
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'–')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div><pre class="code-block light">${esc(json({values:p.values,steps:p.steps}))}</pre><div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
|
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'–')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div>${dataView({values:p.values,steps:p.steps})}<div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
|
||||||
}
|
}
|
||||||
function inspectModule(m) {
|
function inspectModule(m) {
|
||||||
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary><pre class="code-block light">${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}</pre></details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
|
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary>${dataView({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds})}</details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
|
||||||
}
|
}
|
||||||
async function isoForm() {
|
async function isoForm() {
|
||||||
const groups=arr(await api('/groups'));
|
const groups=arr(await api('/groups'));
|
||||||
@@ -294,9 +258,10 @@ function userForm() {
|
|||||||
},'BENUTZER & ROLLEN');
|
},'BENUTZER & ROLLEN');
|
||||||
}
|
}
|
||||||
function secretForm() {
|
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','<div class="alert alert-info">Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.</div>'),async data=>{
|
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.'});
|
||||||
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();
|
showModal('Zugang hinterlegen',form(fields,'Verschlüsselt speichern','<div class="alert alert-info">Den gespeicherten Zugang anschließend im Profil oder Schritt nach seinem Namen auswählen.</div>'),async data=>{
|
||||||
if(result?.id)showModal('Geheimnis gespeichert',`<p class="small-text muted">Referenz für <code>root_secret_id</code> oder einen Schritt:</p><input id="copy-value" value="${esc(result.id)}" readonly aria-label="Geheimnisreferenz"><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}ID kopieren</button><button class="button primary" data-action="close-modal">Fertig</button></div>`);
|
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');
|
},'BETRIEBSGEHEIMNIS');
|
||||||
}
|
}
|
||||||
async function runAction(id, action) {
|
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==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;}
|
||||||
if(action==='refresh'){await refresh();return;}
|
if(action==='refresh'){await refresh();return;}
|
||||||
if(action==='create-host'){await hostForm();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==='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==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;}
|
||||||
if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');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;
|
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==='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==='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==='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==='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==='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==='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')moduleForm(m);else inspectModule(m);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==='publish-module'){await publishObject('modules',id);return;}
|
||||||
if(action==='create-iso'){await isoForm();return;}
|
if(action==='create-iso'){await isoForm();return;}
|
||||||
if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));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');
|
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;
|
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',`<pre class="code-block light">${esc(json(e))}</pre><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
|
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`${dataView(e)}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
|
||||||
}
|
}
|
||||||
document.addEventListener('click',async event=>{
|
document.addEventListener('click',async event=>{
|
||||||
const button=event.target.closest('[data-action]');
|
const button=event.target.closest('[data-action]');
|
||||||
|
|||||||
@@ -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 `<section class="data-editor full" data-editor="${esc(name)}" aria-label="${esc(options.label||editorLabel(name))}">${dataNode(value,options.schema||{},options.label||editorLabel(name))}</section>`;
|
||||||
|
}
|
||||||
|
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?`<span class="data-kind">${esc(editorTypes[type]||type)}</span>`:`<label class="data-kind-picker"><span class="sr-only">Datentyp für ${esc(label)}</span><select data-value-type>${Object.entries(editorTypes).map(([key,text])=>`<option value="${key}"${key===type?' selected':''}>${text}</option>`).join('')}</select></label>`;
|
||||||
|
return `<div class="data-node" data-value-node data-value-kind="${esc(type)}" data-value-schema="${esc(json(schema))}"><div class="data-heading"><strong>${esc(label)}</strong>${choice}</div>${schema.description?`<p class="field-help">${esc(schema.description)}</p>`:''}<div class="data-node-content">${dataNodeContent(value,type,schema,label)}</div></div>`;
|
||||||
|
}
|
||||||
|
function dataNodeContent(value,type,schema,label) {
|
||||||
|
if(Object.hasOwn(schema,'const'))return `<p class="field-help">Fest vorgegeben: ${esc(String(schema.const))}</p>`;
|
||||||
|
if(Array.isArray(schema.enum)) {
|
||||||
|
const choices=[...schema.enum];
|
||||||
|
if(value!==undefined&&!choices.some(item=>json(item)===json(value)))choices.push(value);
|
||||||
|
return `<label><span class="sr-only">${esc(label)}</span><select data-enum-value>${choices.map(item=>`<option value="${esc(json(item))}"${json(item)===json(value)?' selected':''}>${esc(typeof item==='object'?editorType(item):String(item))}</option>`).join('')}</select></label>`;
|
||||||
|
}
|
||||||
|
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?`<label class="data-add-choice"><span class="sr-only">Feld hinzufügen</span><select data-new-property>${allowed.map(key=>`<option value="${esc(key)}">${esc(properties[key]?.title||editorLabel(key))}</option>`).join('')}${schema.additionalProperties!==false?'<option value="">Eigenes Feld</option>':''}</select></label>`:'';
|
||||||
|
const addAllowed=!object||schema.additionalProperties!==false||allowed.length;
|
||||||
|
return `<div class="data-children">${children}</div>${addAllowed?`<div class="data-add">${picker}<button type="button" class="button small" data-edit-action="add">${svg('plus')}${object?'Feld hinzufügen':'Eintrag hinzufügen'}</button></div>`:''}`;
|
||||||
|
}
|
||||||
|
if(type==='boolean')return `<label class="checkbox"><input type="checkbox" data-scalar-value${value?' checked':''}><span>${esc(label)}</span></label>`;
|
||||||
|
if(type==='null')return '<p class="field-help">Kein Wert gesetzt.</p>';
|
||||||
|
if(type==='number'||type==='integer')return `<label><span class="sr-only">${esc(label)}</span><input type="number" data-scalar-value value="${esc(value??0)}" step="${type==='integer'?'1':'any'}"${schema.minimum!==undefined?` min="${esc(schema.minimum)}"`:''}${schema.maximum!==undefined?` max="${esc(schema.maximum)}"`:''} required></label>`;
|
||||||
|
return `<label><span class="sr-only">${esc(label)}</span><textarea data-scalar-value rows="${String(value??'').includes('\n')?'4':'1'}"${schema.minLength?` minlength="${Number(schema.minLength)}"`:''}${schema.maxLength?` maxlength="${Number(schema.maxLength)}"`:''}>${esc(value??'')}</textarea></label>`;
|
||||||
|
}
|
||||||
|
function dataEntry(key,value,object,schema,index,required=false,known=false) {
|
||||||
|
const title=schema.title|| (object?editorLabel(key):`Eintrag ${index+1}`);
|
||||||
|
return `<div class="data-entry" data-entry>${object?`<label class="data-key-label"${known?' hidden':''}>Feldname<input data-entry-key value="${esc(key)}" required${required||known?' readonly':''}></label>`:''}<div class="data-entry-value">${dataNode(value,schema,title)}</div><div class="data-entry-actions">${!object?'<button type="button" class="icon-button" data-edit-action="up" aria-label="Eintrag nach oben">↑</button><button type="button" class="icon-button" data-edit-action="down" aria-label="Eintrag nach unten">↓</button>':''}${!required?'<button type="button" class="icon-button" data-edit-action="remove" aria-label="Eintrag entfernen">×</button>':'<span class="field-help">Pflichtfeld</span>'}</div></div>`;
|
||||||
|
}
|
||||||
|
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 '<span class="muted">Nicht gesetzt</span>';
|
||||||
|
if(typeof value==='boolean')return `<span class="badge ${value?'green':''}">${value?'Ja':'Nein'}</span>`;
|
||||||
|
if(Array.isArray(value))return value.length?`<ol class="data-view-list">${value.map(item=>`<li>${dataView(item)}</li>`).join('')}</ol>`:'<span class="muted">Keine Einträge</span>';
|
||||||
|
if(typeof value==='object')return Object.keys(value).length?`<dl class="data-view">${Object.entries(value).map(([key,item])=>`<dt>${esc(editorLabel(key))}</dt><dd>${dataView(item)}</dd>`).join('')}</dl>`:'<span class="muted">Keine zusätzlichen Einstellungen</span>';
|
||||||
|
return `<span class="data-text">${esc(value)}</span>`;
|
||||||
|
}
|
||||||
@@ -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%}}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function identityRow(identity={kind:'serial',value:''}) {
|
||||||
|
return `<div class="identity-row" data-identity-row>${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})}<button type="button" class="icon-button" data-host-action="remove-identity" aria-label="Identität entfernen">×</button></div>`;
|
||||||
|
}
|
||||||
|
function identitiesEditor(identities=[]) {
|
||||||
|
return `<section class="form-section full" data-identities><h3>Hardware erkennen</h3><p class="field-help">Mindestens eine Kennung ordnet diesen Server beim Start zu.</p><div data-identity-rows>${(identities.length?identities:[{kind:'serial',value:''}]).map(identityRow).join('')}</div><button type="button" class="button small" data-host-action="add-identity">${svg('plus')}Kennung hinzufügen</button></section>`;
|
||||||
|
}
|
||||||
|
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=`<section class="form-section full"><h3>Server</h3><div class="form-grid">${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'}}})}</div></section>${identitiesEditor(arr(h.identities))}<section class="form-section full"><h3>Installation zuweisen</h3><div class="form-grid">${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')})}</div></section><details class="form-section full"><summary>Abweichende Einstellungen für diesen Server</summary><p class="field-help">Ohne Abweichung gelten die Werte des zugewiesenen Profils.</p>${installationEditor(h.overrides||{},{prefix:'host-override',override:true,secrets:arr(secrets)})}</details>`;
|
||||||
|
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 `<section class="form-section" data-import-host><div class="section-heading"><h3>Server</h3><button type="button" class="icon-button" data-host-action="remove-host" aria-label="Server entfernen">×</button></div><div class="form-grid">${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()}</div></section>`;
|
||||||
|
}
|
||||||
|
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')})+`<div class="full stack" data-import-hosts>${importHostRow()}</div><div class="full"><button type="button" class="button" data-host-action="add-host">${svg('plus')}Weiteren Server hinzufügen</button></div>`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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 `<div class="form-grid full" data-installation-network-row>
|
||||||
|
${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'})}
|
||||||
|
<div class="full" data-installation-custom-key${known?' hidden':''}>${field(`${prefix}.interface-custom`,'Name des anderen Merkmals',known?'':key,{full:true})}</div>
|
||||||
|
<button type="button" class="button small" data-installation-remove-interface>Merkmal entfernen</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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) => `<section class="full installation-section" data-installation-section="${esc(name)}">
|
||||||
|
${override?field(`${prefix}.enable.${name}`,`${title} für diesen Host festlegen`,Object.hasOwn(values,name),{type:'checkbox',full:true}):`<h3 class="section-label">${esc(title)}</h3>`}
|
||||||
|
<div class="form-grid" data-installation-section-body>${body}</div></section>`;
|
||||||
|
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.'})
|
||||||
|
+ `<div class="full"><h4>Management-Schnittstelle</h4><p class="small-text muted">Ein oder mehrere Merkmale der gewünschten Netzwerkkarte. Der Installer verwendet diese zur Auswahl.</p><div data-installation-network-rows>${networkRows}</div><button type="button" class="button small" data-installation-add-interface>Merkmal hinzufügen</button></div>`;
|
||||||
|
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})
|
||||||
|
+ `<div class="full alert alert-info" data-installation-all-note>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.</div>`
|
||||||
|
+ `<div class="form-grid full" data-installation-filter-fields>${filtering}</div>`
|
||||||
|
+ 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.'})
|
||||||
|
+ `<div class="form-grid full" data-installation-zfs-fields>${zfsFields}</div>`
|
||||||
|
+ `<div class="form-grid full" data-installation-lvm-fields>${lvmNumbers.map(([key,label,min]) => number(`disk.lvm.${key}`,label,lvm[key],min,1000000)).join('')}</div>`;
|
||||||
|
return `<div class="full installation-editor" data-installation-editor="${esc(prefix)}" data-installation-state="${esc(JSON.stringify({values,override}))}">
|
||||||
|
${override?'<p class="small-text muted">Nicht aktivierte Bereiche und leere Felder übernehmen die veröffentlichten Profilwerte. Gesperrte Profilfelder können nicht überschrieben werden.</p>':''}
|
||||||
|
<div class="form-grid">${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)}</div></div>`;
|
||||||
|
}
|
||||||
|
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); }
|
||||||
@@ -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 = '') => `<button type="button" class="button small" data-module-action="${action}" ${attributes}>${esc(label)}</button>`;
|
||||||
|
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 `<div class="form-grid" data-step-secret data-secret-key="${key}">${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})}<div class="full">${button('Geheimniszuordnung entfernen', 'remove-secret')}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<section class="card" data-postinstall-step data-step-key="${key}" data-original="${metadata(step)}" data-current-module="${esc(step.module_id || '')}">
|
||||||
|
<div class="card-header"><h3 data-step-heading>Schritt</h3><div class="header-actions">${button('↑ Nach oben', 'step-up')}${button('↓ Nach unten', 'step-down')}${button('Entfernen', 'remove-step')}</div></div>
|
||||||
|
<div class="card-content stack"><div class="form-grid">${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})}</div>
|
||||||
|
<div data-step-parameters>${stepParameters(key, step.parameters || {}, module)}</div>
|
||||||
|
<details${Object.keys(step.secret_refs || {}).length ? ' open' : ''}><summary>Geheimnisse für diesen Schritt</summary><p class="small-text muted">Gespeicherte Geheimnisse anhand ihres Namens zuordnen. Die Werte bleiben verborgen.</p><div class="stack" data-step-secrets>${Object.entries(step.secret_refs || {}).map(([name, value]) => secretRow(name, value, secrets)).join('')}</div>${button('Geheimnis zuordnen', 'add-secret')}</details></div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postinstall(profile = {}, modules = [], secrets = []) {
|
||||||
|
return `<div class="full stack" data-postinstall-editor data-original="${metadata(profile)}">
|
||||||
|
${dataEditor('postinstall-values', profile.values || {}, {label:'Gemeinsame Profilparameter', schema:{type:'object'}})}
|
||||||
|
<div><h3 class="section-label">Ablauf der Nachkonfiguration</h3><p class="small-text muted">Module auswählen und in der gewünschten Reihenfolge anordnen. Abhängigkeiten müssen vor dem jeweiligen Modul stehen.</p></div>
|
||||||
|
${list(modules).some(item => item.status === 'published') ? '' : '<div class="alert alert-info">Es sind noch keine veröffentlichten Module verfügbar. Übernehmen Sie zunächst eine Modulvorlage und veröffentlichen Sie den geprüften Stand.</div>'}
|
||||||
|
<div class="stack" data-postinstall-steps>${list(profile.steps).map(step => stepCard(step, modules, secrets)).join('')}</div>
|
||||||
|
<p class="small-text muted" data-no-steps${list(profile.steps).length ? ' hidden' : ''}>Fügen Sie den ersten Schritt hinzu. Mindestens ein Schritt muss als Pflichtschritt markiert sein.</p>
|
||||||
|
<div>${button('Schritt hinzufügen', 'add-step')}</div>
|
||||||
|
${field('postinstall-reboot-budget', 'Maximale geplante Neustarts', profile.reboot_budget ?? 1, {type:'number', min:0, max:5, full:true})}
|
||||||
|
<div class="alert alert-danger" data-module-form-error role="alert" hidden></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<section class="card" data-schema-property data-property-key="${key}"><div class="card-header"><strong>Parameter</strong>${button('Parameter entfernen', 'schema-remove-property')}</div><div class="card-content stack"><div class="form-grid">${field(`${key}-name`, 'Parametername', name, {required:true, placeholder:'z. B. servers'})}${field(`${key}-required`, 'Eingabe erforderlich', required, {type:'checkbox'})}</div>${schemaNode(schema, depth + 1)}</div></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaBody(schema, type, key, depth) {
|
||||||
|
if (type === 'custom') return `<div class="stack" data-schema-body>${dataEditor(`${key}-custom`, schema, {label:'Weitere Parameterregeln grafisch bearbeiten'})}</div>`;
|
||||||
|
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 = `<div class="stack" data-schema-properties>${Object.entries(props).map(([name, rule]) => propertyCard(name, rule, required.has(name), depth)).join('')}</div><div>${button('Parameter hinzufügen', 'schema-add-property')}</div>${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'}]})}<div data-schema-additional${additional === 'schema' ? '' : ' hidden'}>${dataEditor(`${key}-additional-schema`, object(schema.additionalProperties) ? schema.additionalProperties : {}, {label:'Regeln für weitere Felder'})}</div>`;
|
||||||
|
} else if (type === 'array') {
|
||||||
|
specific = `${field(`${key}-has-items`, 'Einträge der Liste definieren', own(schema, 'items'), {type:'checkbox'})}<div data-schema-items${own(schema, 'items') ? '' : ' hidden'}>${schemaNode(schema.items ?? {type:'string'}, depth + 1)}</div>${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('<input ', '<input step="any" ')).join('');
|
||||||
|
return `<div class="stack" data-schema-body data-original="${metadata(schema)}">
|
||||||
|
<div class="form-grid">${field(`${key}-title`, 'Anzeigename (optional)', schema.title || '')}${field(`${key}-description`, 'Beschreibung (optional)', schema.description || '')}${limitFields}</div>
|
||||||
|
${specific}
|
||||||
|
<div>${field(`${key}-has-default`, 'Vorgabewert anbieten', own(schema, 'default'), {type:'checkbox'})}<div data-schema-default${own(schema, 'default') ? '' : ' hidden'}>${dataEditor(`${key}-default`, own(schema, 'default') ? schema.default : copy(typeDefaults[type]), {label:'Vorgabewert'})}</div></div>
|
||||||
|
<div>${field(`${key}-has-enum`, 'Auswahl auf vorgegebene Werte beschränken', own(schema, 'enum'), {type:'checkbox'})}<div data-schema-enum${own(schema, 'enum') ? '' : ' hidden'}>${dataEditor(`${key}-enum`, schema.enum || [], {label:'Erlaubte Auswahlwerte', schema:{type:'array'}})}</div></div>
|
||||||
|
<details${Object.keys(extras).length ? ' open' : ''}><summary>Weitere Regeln</summary>${dataEditor(`${key}-extras`, extras, {label:'Weitere Parameterregeln', schema:{type:'object'}})}</details>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="stack" data-schema-node data-schema-key="${key}" data-schema-depth="${depth}" data-current-type="${type}">${field(`${key}-type`, 'Werttyp', type, {type:'select', options})}${schemaBody(schema, type, key, depth)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="full stack" data-module-schema-editor><h3 class="section-label">Parameter des Moduls</h3><p class="small-text muted">Felder, Datentypen und erlaubte Werte festlegen. Diese Angaben werden im Ablaufeditor als Eingabefelder angezeigt.</p>${schemaNode(schema)}<div class="alert alert-danger" data-module-form-error role="alert" hidden></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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); }
|
||||||
@@ -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'})+
|
||||||
|
`<section class="form-section full"><h3>Modul auswählen</h3><div class="form-grid">${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}))]})}<label class="full">Eigene Skriptdatei laden<input type="file" accept=".sh,text/plain,application/x-sh" data-module-file><small>Eine vorhandene Bash-Datei mit check-, apply- und verify-Aufrufen auswählen.</small></label></div><p class="source-status" data-source-status>${source?'Skript übernommen.':'Noch kein Skript ausgewählt.'}</p><details><summary>Skript ansehen</summary><pre class="code-block script-preview" data-source-preview>${esc(source)}</pre></details></section>`+
|
||||||
|
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})+
|
||||||
|
`<div class="full" data-schema-container>${moduleSchemaEditor(schema)}</div>`+
|
||||||
|
`<div class="full" data-dependencies-container>${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)])]}}})}</div>`+
|
||||||
|
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);}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>Proxmox AIS · Provisionierung</title>
|
<title>Proxmox AIS · Provisionierung</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/forms.css">
|
||||||
|
<script src="/static/form-controls.js" defer></script>
|
||||||
|
<script src="/static/installation-form.js" defer></script>
|
||||||
|
<script src="/static/module-forms.js" defer></script>
|
||||||
|
<script src="/static/profile-forms.js" defer></script>
|
||||||
|
<script src="/static/host-forms.js" defer></script>
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "proxmox-ais-server"
|
name = "proxmox-ais-server"
|
||||||
version = "0.9.2"
|
version = "0.9.3"
|
||||||
description = "Controlled Proxmox automated installation and resumable post-installation"
|
description = "Controlled Proxmox automated installation and resumable post-installation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"python-multipart==0.0.32",
|
"python-multipart==0.0.32",
|
||||||
"cryptography==50.0.1",
|
"cryptography==50.0.1",
|
||||||
|
"passlib==1.7.4",
|
||||||
"tomli-w==1.2.0",
|
"tomli-w==1.2.0",
|
||||||
"jsonschema==4.26.0",
|
"jsonschema==4.26.0",
|
||||||
"tzdata==2026.4",
|
"tzdata==2026.4",
|
||||||
@@ -22,6 +23,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest==9.1.1", "httpx==0.28.1"]
|
dev = ["pytest==9.1.1", "httpx==0.28.1"]
|
||||||
|
browser = ["playwright==1.62.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
proxmox-ais = "provisioner.cli:main"
|
proxmox-ais = "provisioner.cli:main"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"]}))
|
||||||
@@ -7,6 +7,7 @@ from http.cookiejar import CookieJar
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
@@ -77,13 +78,18 @@ def main():
|
|||||||
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
|
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:
|
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
|
||||||
assert response.status == 200
|
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:
|
with request("/api/v1/me") as response:
|
||||||
csrf = json.load(response)["csrf_token"]
|
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:
|
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"]
|
host_id = json.load(response)["id"]
|
||||||
with opener.open(base+"/static/app.js",timeout=5) as response:
|
assets = set(re.findall(rb'(?:src|href)="(/static/[^\"]+)"', page))
|
||||||
assert response.status == 200 and len(response.read())>1000
|
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.terminate()
|
||||||
process.wait(timeout=15)
|
process.wait(timeout=15)
|
||||||
process = start()
|
process = start()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user