Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b979e6243 | ||
|
|
9a6dea34c4 |
@@ -62,7 +62,10 @@ jobs:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
- name: Check JavaScript syntax
|
||||
run: node --check provisioner/static/app.js
|
||||
run: |
|
||||
for script in provisioner/static/*.js; do
|
||||
node --check "$script"
|
||||
done
|
||||
|
||||
container-policy:
|
||||
runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }}
|
||||
|
||||
@@ -116,14 +116,20 @@ Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deploy
|
||||
|
||||
## Erster Installationslauf
|
||||
|
||||
Die [Anleitung zur Weboberfläche](docs/web-interface.md) führt durch die
|
||||
grafischen Formulare. Profile, Host-Einstellungen, Modulparameter und
|
||||
Schrittfolgen benötigen keine JSON-Eingabe. Für eigene Skripte stehen
|
||||
Vorlagen und Dateiupload zur Verfügung.
|
||||
|
||||
1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`,
|
||||
`author`, `admin` und `developer`.
|
||||
2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten
|
||||
Token sicher speichern. Er ist Bestandteil des Installationsmediums.
|
||||
3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen.
|
||||
Das Medium erst nach bestandenem Labortest freigeben.
|
||||
4. Root-Zugang als Geheimnis speichern, Installationsprofil und
|
||||
Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und
|
||||
4. Root-Zugang als Geheimnis speichern; ein eingegebenes Root-Passwort wird
|
||||
automatisch in den Installer-Hash umgewandelt. Installationsprofil und
|
||||
Postinstallationsprofil über die Formulare anlegen. Module enthalten `check`, `apply` und
|
||||
`verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig
|
||||
muss eine andere Person als der Autor veröffentlichen.
|
||||
5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen
|
||||
@@ -149,6 +155,18 @@ Unter PowerShell entsprechend `.\.venv\Scripts\python.exe -m pytest` und
|
||||
`.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden.
|
||||
Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen.
|
||||
|
||||
Die optionalen Browserprüfungen testen die grafischen Formulare mit Chromium
|
||||
und einer eigenen lokalen Testdatenbank:
|
||||
|
||||
```bash
|
||||
uv pip install --python .venv/bin/python -e ".[dev,browser]"
|
||||
.venv/bin/python -m playwright install chromium
|
||||
.venv/bin/python tests/browser_forms.py
|
||||
```
|
||||
|
||||
Unter Windows `.venv/Scripts/python.exe` als Python-Pfad verwenden.
|
||||
Screenshots und Testdaten liegen im ignorierten Verzeichnis `.cache/ui-browser/`.
|
||||
|
||||
Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte
|
||||
Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den
|
||||
Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich
|
||||
|
||||
@@ -37,6 +37,28 @@ Secret-ID, konkret geprüfter Build, Management-Interface, Datenträgerseriennum
|
||||
und Inventarisierungsnachweis. `9.1-1` dient lediglich als Formatbeispiel und ist
|
||||
keine Kompatibilitätsfreigabe. FQDN und Management-IP kommen aus dem Hostinventar.
|
||||
|
||||
Für Server, die immer genau eine Zielplatte haben, kann `values.disk_setup`
|
||||
stattdessen ohne Seriennummernfilter und Gerätenamen angegeben werden:
|
||||
|
||||
```json
|
||||
{
|
||||
"filesystem": "zfs",
|
||||
"selection": "all",
|
||||
"zfs": {"raid": "raid0"}
|
||||
}
|
||||
```
|
||||
|
||||
In der Weboberfläche werden dafür **ZFS**, **RAID0 / Einzelplatte** und die
|
||||
automatische Plattenwahl im Profilformular ausgewählt. JSON-Eingaben sind dort
|
||||
nicht erforderlich; die übrigen Profilparameter besitzen eigene Formularfelder.
|
||||
`selection: "all"` verwendet alle vom Proxmox-Installer erkannten Zielplatten;
|
||||
es prüft nicht, ob tatsächlich nur eine Platte vorhanden ist. Die native Antwort
|
||||
nutzt dafür `filter.DEVTYPE = "disk"`. CD-ROM- und ISO9660-Installationsmedien
|
||||
werden bereits durch die Geräteerkennung des Installers ausgeschlossen.
|
||||
Dieser Modus unterstützt ausschließlich ZFS RAID0. `filter`, `filter_match`,
|
||||
`expected_count` und `expected_serials` dürfen dabei nicht angegeben werden.
|
||||
Bei mehreren Zielplatten eine ausdrückliche Auswahl per Seriennummer/WWN verwenden.
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error --cookie session.cookies \
|
||||
--header "X-CSRF-Token: $AIS_CSRF" \
|
||||
|
||||
+10
-1
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Proxmox AIS",
|
||||
"description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.",
|
||||
"version": "0.9.1"
|
||||
"version": "0.9.3"
|
||||
},
|
||||
"paths": {
|
||||
"/health/live": {
|
||||
@@ -2371,6 +2371,15 @@
|
||||
"minLength": 1,
|
||||
"title": "Name"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"value",
|
||||
"root_password"
|
||||
],
|
||||
"title": "Kind",
|
||||
"default": "value"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 16384,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
__version__ = "0.9.1"
|
||||
__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)
|
||||
|
||||
@app.get("/api/v1/secrets")
|
||||
def secrets_list(user=Depends(roles())):
|
||||
def secrets_list(user=Depends(roles("author","operator"))):
|
||||
with db.connection() as connection:
|
||||
return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")]
|
||||
|
||||
@app.post("/api/v1/secrets",status_code=201)
|
||||
def create_secret(payload:SecretCreate,user=Depends(roles())):
|
||||
value = security.hash_installer_password(payload.value) if payload.kind == "root_password" else payload.value
|
||||
with db.connection(write=True) as connection:
|
||||
secret_id = new_id("secret")
|
||||
connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso()))
|
||||
connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(value),now_iso()))
|
||||
audit(connection,user["id"],"secret.created",secret_id)
|
||||
return {"id":secret_id,"name":payload.name}
|
||||
|
||||
|
||||
+15
-3
@@ -1,9 +1,9 @@
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
from ipaddress import ip_interface
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationInfo, field_validator
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
@@ -156,7 +156,19 @@ class UserCreate(Model):
|
||||
|
||||
class SecretCreate(Model):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
value: str = Field(min_length=1, max_length=16384)
|
||||
kind: Literal["value", "root_password"] = "value"
|
||||
value: Annotated[str, StringConstraints(strip_whitespace=False)] = Field(min_length=1, max_length=16384)
|
||||
|
||||
@field_validator("value", mode="before")
|
||||
@classmethod
|
||||
def secret_value(cls, value, info: ValidationInfo):
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if info.data.get("kind") == "root_password":
|
||||
if not 12 <= len(value) <= 1024 or "\x00" in value:
|
||||
raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
|
||||
return value
|
||||
return value.strip()
|
||||
|
||||
|
||||
class Enroll(Model):
|
||||
|
||||
@@ -8,6 +8,13 @@ import re
|
||||
import secrets
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from passlib.hash import sha512_crypt
|
||||
|
||||
|
||||
# The installer requires a Linux crypt hash. The builtin backend also works
|
||||
# on Python 3.13+, where the standard-library crypt module no longer exists.
|
||||
_installer_password_hash = sha512_crypt.using(rounds=656000)
|
||||
_installer_password_hash.set_backend("builtin")
|
||||
|
||||
|
||||
def canonical(value):
|
||||
@@ -43,6 +50,12 @@ class Security:
|
||||
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
|
||||
return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode()
|
||||
|
||||
@staticmethod
|
||||
def hash_installer_password(password):
|
||||
if not 12 <= len(password) <= 1024 or "\x00" in password:
|
||||
raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
|
||||
return _installer_password_hash.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password, stored):
|
||||
try:
|
||||
|
||||
+19
-2
@@ -199,6 +199,8 @@ class Service:
|
||||
seen_names[module["name"]] = step["id"]
|
||||
require(steps and any(step["required"] for step in steps), 422, "Mindestens eine verpflichtende Abschlussprüfung ist erforderlich.")
|
||||
snapshot = {"resolved": resolved, "provenance": provenance, "profiles": [{"id": p["id"], "name": p["name"], "version": p["version"], "digest": p["digest"]} for p in (install,post)], "steps": steps, "disks": resolved["disk_setup"], "iso": iso, "identities": host["identities"], "reboot_budget": post.get("reboot_budget",1), "warnings": ["Die Hardwarekennung dient der Zuordnung im kontrollierten Provisionierungsnetz."]}
|
||||
if resolved["disk_setup"].get("selection") == "all":
|
||||
snapshot["warnings"].append("Automatische Datenträgerwahl: Alle vom Installer erkannten Zielplatten werden verwendet. Dieses Profil ist für Server mit genau einer Zielplatte vorgesehen.")
|
||||
snapshot["digest"] = digest(canonical(snapshot))
|
||||
return snapshot, secrets_snapshot
|
||||
|
||||
@@ -234,8 +236,16 @@ class Service:
|
||||
require(isinstance(network["filter"],dict) and all(isinstance(k,str) and isinstance(v,str) and v and v != "*" for k,v in network["filter"].items()), 422, "Expliziter Interface-Filter erforderlich.")
|
||||
disks = values.get("disk_setup", {})
|
||||
require(isinstance(disks,dict),422,"Datenträgerparameter müssen ein Objekt sein.")
|
||||
require(set(disks) <= {"filesystem", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.")
|
||||
require(set(disks) <= {"filesystem", "selection", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.")
|
||||
require(isinstance(disks.get("filesystem"),str) and disks["filesystem"] in {"ext4", "xfs", "zfs"}, 422, "Unterstützte Dateisysteme: ext4, xfs, zfs.")
|
||||
select_all = "selection" in disks
|
||||
if select_all:
|
||||
require(disks["selection"] == "all", 422, "Unbekannte automatische Datenträgerwahl.")
|
||||
require(not set(disks) & {"filter", "filter_match", "expected_count", "expected_serials"}, 422, "Automatische Datenträgerwahl darf nicht mit Filtern oder erwarteten Datenträgern kombiniert werden.")
|
||||
require(disks["filesystem"] == "zfs", 422, "Automatische Datenträgerwahl benötigt ZFS RAID0.")
|
||||
if "inventory_evidence" in disks:
|
||||
require(isinstance(disks["inventory_evidence"],str) and bool(disks["inventory_evidence"].strip()), 422, "Ein angegebener Inventarisierungsnachweis darf nicht leer sein.")
|
||||
else:
|
||||
filters = disks.get("filter", {})
|
||||
require(isinstance(filters,dict) and bool(filters) and set(filters) <= {"ID_SERIAL", "ID_SERIAL_SHORT", "ID_WWN"}, 422, "Datenträger benötigen stabile Seriennummer- oder WWN-Filter.")
|
||||
serials = disks.get("expected_serials", [])
|
||||
@@ -258,6 +268,9 @@ class Service:
|
||||
raid = zfs.get("raid")
|
||||
minimum = {"raid0":1,"raid1":2,"raid10":4,"raidz-1":3,"raidz-2":4,"raidz-3":5}
|
||||
require(isinstance(raid,str) and raid in minimum, 422, "ZFS benötigt einen expliziten RAID-Modus.")
|
||||
if select_all:
|
||||
require(raid == "raid0", 422, "Automatische Datenträgerwahl benötigt ZFS RAID0.")
|
||||
else:
|
||||
require(len(serials)>=minimum[raid] and (raid!="raid10" or len(serials)%2==0),422,"Datenträgeranzahl passt nicht zum ZFS-RAID-Modus.")
|
||||
for key,lower,upper in (("ashift",9,16),("arc-max",64,1048576),("copies",1,3),("hdsize",2,1000000)):
|
||||
if key in zfs:
|
||||
@@ -373,7 +386,11 @@ class Service:
|
||||
resolved = deepcopy(run["snapshot"]["resolved"])
|
||||
resolved["global"]["root-password-hashed"] = json.loads(self.security.decrypt(run["secrets_ciphertext"]))["root"]
|
||||
disks = resolved["disk_setup"]
|
||||
native_disks = {k:v for k,v in disks.items() if k not in {"expected_count","expected_serials","inventory_evidence","filter_match"}}
|
||||
native_disks = {k:v for k,v in disks.items() if k not in {"selection","expected_count","expected_serials","inventory_evidence","filter_match"}}
|
||||
if disks.get("selection") == "all":
|
||||
native_disks["filter"] = {"DEVTYPE":"disk"}
|
||||
native_disks["filter-match"] = "all"
|
||||
else:
|
||||
native_disks["filter-match"] = disks.get("filter_match","all")
|
||||
answer_data = {"global":resolved["global"],"network":resolved["network"],"disk-setup":native_disks,"first-boot":{"source":"from-url","ordering":"network-online","url":self.settings.public_url + "/bootstrap/v1/" + bootstrap_token,"cert-fingerprint":iso["fingerprint"]},"post-installation-webhook":{"url":self.settings.public_url + "/installer/v1/report/" + report_token,"cert-fingerprint":iso["fingerprint"]}}
|
||||
answer = tomli_w.dumps(answer_data)
|
||||
|
||||
+21
-56
@@ -128,12 +128,12 @@ function dashboard(data) {
|
||||
}
|
||||
function hostsPage(hosts, discoveries=[]) {
|
||||
const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase()))));
|
||||
return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`<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) {
|
||||
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))+
|
||||
`<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) {
|
||||
const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind);
|
||||
@@ -151,10 +151,10 @@ function runPage(run) {
|
||||
const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested;
|
||||
const actions=`<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='';
|
||||
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==='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=[]) {
|
||||
return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+
|
||||
@@ -180,7 +180,7 @@ function closeModal() {modal.close();modalSubmit=null;document.getElementById('m
|
||||
function field(name,label,value='',options={}) {
|
||||
const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`;
|
||||
let input;
|
||||
if(options.type==='textarea'||options.type==='json') input=`<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==='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)}">`;
|
||||
@@ -189,24 +189,10 @@ function field(name,label,value='',options={}) {
|
||||
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>`;
|
||||
}
|
||||
function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}}
|
||||
const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean);
|
||||
const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))];
|
||||
async function hostForm(existing=null, discovery=null) {
|
||||
const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
|
||||
const h=existing||discovery||{};
|
||||
const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'});
|
||||
showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{
|
||||
const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};});
|
||||
const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')};
|
||||
if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;}
|
||||
await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh();
|
||||
},'INVENTAR');
|
||||
}
|
||||
function hostImportForm() {
|
||||
const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}];
|
||||
showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT');
|
||||
}
|
||||
async function hostForm(existing=null, discovery=null) { return graphicalHostForm(existing,discovery); }
|
||||
async function hostImportForm() { return graphicalHostImport(); }
|
||||
async function moduleCatalog() {
|
||||
const catalog=arr(await api('/modules/builtin'));
|
||||
showModal('Basismodul als Entwurf übernehmen',`<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) {
|
||||
const p=preview||{}, warnings=arr(p.warnings);
|
||||
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) {
|
||||
const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]);
|
||||
@@ -228,30 +214,8 @@ async function approveHost(id, previewOnly=false) {
|
||||
closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh();
|
||||
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
|
||||
}
|
||||
const installationExample = {
|
||||
global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'},
|
||||
network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}},
|
||||
disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'},
|
||||
root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES'
|
||||
};
|
||||
async function profileForm(kind, existing=null) {
|
||||
const p=existing||{}, install=kind==='installation';
|
||||
let info='';
|
||||
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 profileForm(kind, existing=null) { return graphicalProfileForm(kind,existing); }
|
||||
async function moduleForm(existing=null) { return graphicalModuleForm(existing); }
|
||||
async function publishObject(type, id) {
|
||||
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
|
||||
const intro=`<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');
|
||||
}
|
||||
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) {
|
||||
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() {
|
||||
const groups=arr(await api('/groups'));
|
||||
@@ -294,9 +258,10 @@ function userForm() {
|
||||
},'BENUTZER & ROLLEN');
|
||||
}
|
||||
function secretForm() {
|
||||
showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','<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 result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh();
|
||||
if(result?.id)showModal('Geheimnis gespeichert',`<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>`);
|
||||
const fields=field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Zugang · PVE Berlin'})+field('kind','Verwendung','root_password',{type:'select',full:true,options:[{value:'root_password',label:'Root-Passwort für die Proxmox-Installation'},{value:'value',label:'Anderes Geheimnis oder vorhandener Passwort-Hash'}]})+field('value','Passwort / Geheimnis','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für ein neues Root-Passwort mindestens 12 Zeichen eingeben. Das Tool bereitet es für den Installer auf.'});
|
||||
showModal('Zugang hinterlegen',form(fields,'Verschlüsselt speichern','<div class="alert alert-info">Den gespeicherten Zugang anschließend im Profil oder Schritt nach seinem Namen auswählen.</div>'),async data=>{
|
||||
if(data.get('kind')==='root_password'&&String(data.get('value')).length<12)throw new Error('Das Root-Passwort benötigt mindestens 12 Zeichen.');
|
||||
await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value'),kind:data.get('kind')}});closeModal();toast('Zugang gespeichert und in den Auswahllisten verfügbar.');state.settingsTab='secrets';await refresh();
|
||||
},'BETRIEBSGEHEIMNIS');
|
||||
}
|
||||
async function runAction(id, action) {
|
||||
@@ -359,7 +324,7 @@ async function handleAction(button) {
|
||||
if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;}
|
||||
if(action==='refresh'){await refresh();return;}
|
||||
if(action==='create-host'){await hostForm();return;}
|
||||
if(action==='import-hosts'){hostImportForm();return;}
|
||||
if(action==='import-hosts'){await hostImportForm();return;}
|
||||
if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;}
|
||||
if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;}
|
||||
if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;}
|
||||
@@ -371,10 +336,10 @@ async function handleAction(button) {
|
||||
if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;}
|
||||
if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;}
|
||||
if(action==='publish-profile'){await publishObject('profiles',id);return;}
|
||||
if(action==='create-module'){moduleForm();return;}
|
||||
if(action==='create-module'){await moduleForm();return;}
|
||||
if(action==='module-catalog'){await moduleCatalog();return;}
|
||||
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;}
|
||||
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;}
|
||||
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);await moduleForm({...selected,version:0});return;}
|
||||
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')await moduleForm(m);else inspectModule(m);return;}
|
||||
if(action==='publish-module'){await publishObject('modules',id);return;}
|
||||
if(action==='create-iso'){await isoForm();return;}
|
||||
if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;}
|
||||
@@ -389,7 +354,7 @@ async function handleAction(button) {
|
||||
const el=document.getElementById('copy-value');
|
||||
try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return;
|
||||
}
|
||||
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`<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=>{
|
||||
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">
|
||||
<title>Proxmox AIS · Provisionierung</title>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "proxmox-ais-server"
|
||||
version = "0.9.1"
|
||||
version = "0.9.3"
|
||||
description = "Controlled Proxmox automated installation and resumable post-installation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"jinja2==3.1.6",
|
||||
"python-multipart==0.0.32",
|
||||
"cryptography==50.0.1",
|
||||
"passlib==1.7.4",
|
||||
"tomli-w==1.2.0",
|
||||
"jsonschema==4.26.0",
|
||||
"tzdata==2026.4",
|
||||
@@ -22,6 +23,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest==9.1.1", "httpx==0.28.1"]
|
||||
browser = ["playwright==1.62.0"]
|
||||
|
||||
[project.scripts]
|
||||
proxmox-ais = "provisioner.cli:main"
|
||||
|
||||
@@ -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 os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
@@ -77,13 +78,18 @@ def main():
|
||||
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
|
||||
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
|
||||
assert response.status == 200
|
||||
assert b"/static/app.js" in response.read()
|
||||
page = response.read()
|
||||
assert b"/static/app.js" in page
|
||||
with request("/api/v1/me") as response:
|
||||
csrf = json.load(response)["csrf_token"]
|
||||
with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response:
|
||||
host_id = json.load(response)["id"]
|
||||
with opener.open(base+"/static/app.js",timeout=5) as response:
|
||||
assert response.status == 200 and len(response.read())>1000
|
||||
assets = set(re.findall(rb'(?:src|href)="(/static/[^\"]+)"', page))
|
||||
assert b"/static/installation-form.js" in assets
|
||||
assert b"/static/module-forms.js" in assets
|
||||
for asset in assets:
|
||||
with opener.open(base+asset.decode(),timeout=5) as response:
|
||||
assert response.status == 200 and response.read(), asset
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
process = start()
|
||||
|
||||
@@ -58,8 +58,7 @@ def environment(tmp_path):
|
||||
yield app, client, csrf
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prepared(environment):
|
||||
def prepare_host(environment, disk_setup=None, approve=True):
|
||||
app, client, csrf = environment
|
||||
secret = post(client, "/api/v1/secrets", {"name": "test-root", "value": ROOT_HASH}, csrf)
|
||||
group = post(client, "/api/v1/groups", {"name": "test-lab", "site": "lab", "valid_hours": 1}, csrf)
|
||||
@@ -74,6 +73,8 @@ def prepared(environment):
|
||||
post(client, f"/api/v1/modules/{module['id']}/publish", publication, csrf)
|
||||
profile_data = json.loads((Path(__file__).parents[1] / "docs" / "sample-profile.json").read_text())
|
||||
profile_data["values"]["root_secret_id"] = secret["id"]
|
||||
if disk_setup is not None:
|
||||
profile_data["values"]["disk_setup"] = deepcopy(disk_setup)
|
||||
installation = post(client, "/api/v1/profiles", profile_data, csrf)
|
||||
post(client, f"/api/v1/profiles/{installation['id']}/publish", publication, csrf)
|
||||
postinstall = post(client, "/api/v1/profiles", {"name": "test-postinstall", "kind": "postinstall",
|
||||
@@ -86,7 +87,7 @@ def prepared(environment):
|
||||
"installation_profile_id": installation["id"], "postinstall_profile_id": postinstall["id"], "iso_id": iso["id"]}, csrf)
|
||||
run = post(client, f"/api/v1/hosts/{host['id']}/approve-install", {
|
||||
"expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"],
|
||||
"disks_confirmed": True, "reason": "Dedicated simulated test host"}, csrf)
|
||||
"disks_confirmed": True, "reason": "Dedicated simulated test host"}, csrf) if approve else None
|
||||
payload = {"$schema": {"version": "1.0"}, "product": {"product": "pve"},
|
||||
"iso": {"release": "9.1", "build": "1"}, "dmi": {"system": {"uuid": HOST_UUID, "serial": "LAB-HOST-001"}},
|
||||
"network-interfaces": [{"mac": HOST_MAC}]}
|
||||
@@ -95,6 +96,11 @@ def prepared(environment):
|
||||
"host": host, "run": run, "payload": payload, "identities": identities}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prepared(environment):
|
||||
return prepare_host(environment)
|
||||
|
||||
|
||||
def answer(prepared, payload=None):
|
||||
return prepared["client"].post("/installer/v1/answer", json=payload or prepared["payload"],
|
||||
headers={"Authorization": f"Bearer {prepared['group']['token']}"})
|
||||
@@ -185,12 +191,98 @@ def test_concurrent_installer_retries_reserve_one_immutable_answer(prepared):
|
||||
native = tomllib.loads(responses[0].text)
|
||||
assert native["global"]["fqdn"] == prepared["host"]["fqdn"]
|
||||
assert native["disk-setup"]["filter"] == {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}
|
||||
assert native["disk-setup"]["filter-match"] == "all"
|
||||
assert "selection" not in native["disk-setup"]
|
||||
assert "expected_count" not in native["disk-setup"]
|
||||
assert prepared["run"]["snapshot"]["warnings"] == [
|
||||
"Die Hardwarekennung dient der Zuordnung im kontrollierten Provisionierungsnetz."]
|
||||
with prepared["app"].state.db.connection() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM runs").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT status FROM approvals").fetchone()[0] == "consumed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evidence", [None, "Single-target-disk laboratory inventory"])
|
||||
def test_automatic_zfs_raid0_preview_approval_and_native_answer(environment, evidence):
|
||||
disk_setup = {"filesystem": "zfs", "selection": "all", "zfs": {"raid": "raid0", "ashift": 12}}
|
||||
if evidence is not None:
|
||||
disk_setup["inventory_evidence"] = evidence
|
||||
configured = prepare_host(environment, disk_setup, approve=False)
|
||||
client, csrf, host = configured["client"], configured["csrf"], configured["host"]
|
||||
preview = client.get(f"/api/v1/hosts/{host['id']}/preview")
|
||||
assert preview.status_code == 200, preview.text
|
||||
snapshot = preview.json()
|
||||
assert snapshot["disks"] == disk_setup
|
||||
assert snapshot["warnings"][-1] == (
|
||||
"Automatische Datenträgerwahl: Alle vom Installer erkannten Zielplatten werden verwendet. "
|
||||
"Dieses Profil ist für Server mit genau einer Zielplatte vorgesehen.")
|
||||
approval = {"expected_version": host["version"], "valid_minutes": 30,
|
||||
"confirmation": host["fqdn"], "disks_confirmed": False, "reason": "Single-target-disk simulation"}
|
||||
endpoint = f"/api/v1/hosts/{host['id']}/approve-install"
|
||||
assert client.post(endpoint, json=approval, headers=csrf).status_code == 422
|
||||
configured["run"] = post(client, endpoint, {**approval, "disks_confirmed": True}, csrf)
|
||||
assert configured["run"]["snapshot"] == snapshot
|
||||
response = answer(configured)
|
||||
assert response.status_code == 200, response.text
|
||||
assert tomllib.loads(response.text)["disk-setup"] == {
|
||||
"filesystem": "zfs", "zfs": {"raid": "raid0", "ashift": 12},
|
||||
"filter": {"DEVTYPE": "disk"}, "filter-match": "all"}
|
||||
assert answer(configured).text == response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [
|
||||
{"filter": {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}},
|
||||
{"filter": {}},
|
||||
{"filter_match": "all"},
|
||||
{"expected_count": 1},
|
||||
{"expected_serials": []},
|
||||
{"filesystem": "ext4"},
|
||||
{"filesystem": "xfs"},
|
||||
{"zfs": {"raid": "raid1"}},
|
||||
{"zfs": {"raid": "raid10"}},
|
||||
{"zfs": {"raid": "raidz-1"}},
|
||||
{"zfs": {}},
|
||||
{"zfs": {"raid": "raid0", "ashift": True}},
|
||||
{"zfs": {"raid": "raid0", "copies": 4}},
|
||||
{"lvm": {}},
|
||||
{"selection": "ALL"},
|
||||
{"selection": None},
|
||||
{"selection": True},
|
||||
{"selection": ["all"]},
|
||||
{"inventory_evidence": ""},
|
||||
{"inventory_evidence": " "},
|
||||
{"inventory_evidence": None},
|
||||
{"disk_list": ["sda"]},
|
||||
])
|
||||
def test_automatic_disk_selection_rejects_ambiguous_or_unsupported_profiles(environment, override):
|
||||
disk_setup = {"filesystem": "zfs", "selection": "all", "zfs": {"raid": "raid0"}, **override}
|
||||
configured = prepare_host(environment, disk_setup, approve=False)
|
||||
client, csrf, host = configured["client"], configured["csrf"], configured["host"]
|
||||
assert client.get(f"/api/v1/hosts/{host['id']}/preview").status_code == 422
|
||||
response = client.post(f"/api/v1/hosts/{host['id']}/approve-install", json={
|
||||
"expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"],
|
||||
"disks_confirmed": True, "reason": "Invalid selection must not authorize an install"}, headers=csrf)
|
||||
assert response.status_code == 422, response.text
|
||||
with configured["app"].state.db.connection() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM approvals").fetchone()[0] == 0
|
||||
assert connection.execute("SELECT count(*) FROM runs").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disk_setup", [
|
||||
{"filesystem": "zfs", "zfs": {"raid": "raid0"}},
|
||||
{"filesystem": "zfs", "zfs": {"raid": "raid0"}, "filter": {"DEVTYPE": "disk"}},
|
||||
{"filesystem": "zfs", "zfs": {"raid": "raid0"},
|
||||
"filter": {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}, "expected_count": 1,
|
||||
"expected_serials": ["LAB_SYSTEM_DISK_001"]},
|
||||
{"filesystem": "zfs", "zfs": {"raid": "raid1"},
|
||||
"filter": {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}, "expected_count": 1,
|
||||
"expected_serials": ["LAB_SYSTEM_DISK_001"], "inventory_evidence": "Verified one-disk inventory"},
|
||||
])
|
||||
def test_filtered_disk_selection_keeps_existing_requirements(environment, disk_setup):
|
||||
configured = prepare_host(environment, disk_setup, approve=False)
|
||||
response = configured["client"].get(f"/api/v1/hosts/{configured['host']['id']}/preview")
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
def test_new_profile_version_cannot_change_prepared_run(prepared):
|
||||
original = prepared["run"]["snapshot"]
|
||||
data = deepcopy(prepared["profile_data"])
|
||||
|
||||
@@ -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