diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 9b55d2e..2d81b07 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -62,7 +62,10 @@ jobs:
node-version: "24"
package-manager-cache: false
- name: Check JavaScript syntax
- run: node --check provisioner/static/app.js
+ run: |
+ for script in provisioner/static/*.js; do
+ node --check "$script"
+ done
container-policy:
runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }}
diff --git a/README.md b/README.md
index 62799c0..314108c 100644
--- a/README.md
+++ b/README.md
@@ -116,14 +116,20 @@ Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deploy
## Erster Installationslauf
+Die [Anleitung zur Weboberfläche](docs/web-interface.md) führt durch die
+grafischen Formulare. Profile, Host-Einstellungen, Modulparameter und
+Schrittfolgen benötigen keine JSON-Eingabe. Für eigene Skripte stehen
+Vorlagen und Dateiupload zur Verfügung.
+
1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`,
`author`, `admin` und `developer`.
2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten
Token sicher speichern. Er ist Bestandteil des Installationsmediums.
3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen.
Das Medium erst nach bestandenem Labortest freigeben.
-4. Root-Zugang als Geheimnis speichern, Installationsprofil und
- Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und
+4. Root-Zugang als Geheimnis speichern; ein eingegebenes Root-Passwort wird
+ automatisch in den Installer-Hash umgewandelt. Installationsprofil und
+ Postinstallationsprofil über die Formulare anlegen. Module enthalten `check`, `apply` und
`verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig
muss eine andere Person als der Autor veröffentlichen.
5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen
@@ -149,6 +155,18 @@ Unter PowerShell entsprechend `.\.venv\Scripts\python.exe -m pytest` und
`.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden.
Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen.
+Die optionalen Browserprüfungen testen die grafischen Formulare mit Chromium
+und einer eigenen lokalen Testdatenbank:
+
+```bash
+uv pip install --python .venv/bin/python -e ".[dev,browser]"
+.venv/bin/python -m playwright install chromium
+.venv/bin/python tests/browser_forms.py
+```
+
+Unter Windows `.venv/Scripts/python.exe` als Python-Pfad verwenden.
+Screenshots und Testdaten liegen im ignorierten Verzeichnis `.cache/ui-browser/`.
+
Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte
Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den
Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich
diff --git a/docs/api-workflow.md b/docs/api-workflow.md
index cd9a149..f388246 100644
--- a/docs/api-workflow.md
+++ b/docs/api-workflow.md
@@ -48,8 +48,9 @@ stattdessen ohne Seriennummernfilter und Gerätenamen angegeben werden:
}
```
-In der Weboberfläche trägt **ZFS (RAID0) für Einzelplatte** diese Einstellung
-im Profilformular ein. Die übrigen Profilparameter werden dabei beibehalten.
+In der Weboberfläche werden dafür **ZFS**, **RAID0 / Einzelplatte** und die
+automatische Plattenwahl im Profilformular ausgewählt. JSON-Eingaben sind dort
+nicht erforderlich; die übrigen Profilparameter besitzen eigene Formularfelder.
`selection: "all"` verwendet alle vom Proxmox-Installer erkannten Zielplatten;
es prüft nicht, ob tatsächlich nur eine Platte vorhanden ist. Die native Antwort
nutzt dafür `filter.DEVTYPE = "disk"`. CD-ROM- und ISO9660-Installationsmedien
diff --git a/docs/openapi.json b/docs/openapi.json
index d437e42..6168a55 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "Proxmox AIS",
"description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.",
- "version": "0.9.2"
+ "version": "0.9.3"
},
"paths": {
"/health/live": {
@@ -2371,6 +2371,15 @@
"minLength": 1,
"title": "Name"
},
+ "kind": {
+ "type": "string",
+ "enum": [
+ "value",
+ "root_password"
+ ],
+ "title": "Kind",
+ "default": "value"
+ },
"value": {
"type": "string",
"maxLength": 16384,
diff --git a/docs/web-interface.md b/docs/web-interface.md
new file mode 100644
index 0000000..a03a70e
--- /dev/null
+++ b/docs/web-interface.md
@@ -0,0 +1,60 @@
+# Bedienung über die Weboberfläche
+
+Profile, Server, Schritte und Modulparameter werden über Formularfelder,
+Auswahllisten sowie Schaltflächen zum Hinzufügen, Entfernen und Sortieren
+bearbeitet. Die Anwendung erzeugt die API-Konfiguration intern.
+
+## Root-Zugang anlegen
+
+Unter **Einstellungen → Geheimnisse** einen Zugang mit einer Bezeichnung
+anlegen. Als Verwendung **Root-Passwort für die Proxmox-Installation** auswählen
+und ein Passwort mit mindestens zwölf Zeichen eingeben. Das Tool erzeugt
+den SHA512-crypt-Hash und speichert ihn verschlüsselt. Das eingegebene Passwort
+und der Hash werden nicht erneut angezeigt. Andere Geheimnisse oder vorhandene
+Hashes lassen sich über die zweite Verwendung unverändert hinterlegen.
+
+## Installationsprofil
+
+Unter **Installationsprofile → Profil erstellen** den Namen und die geprüften
+Zielbuilds eintragen. Weitere Builds werden mit **Eintrag hinzufügen** ergänzt.
+Sprache, Zeitzone, E-Mail, Netzwerk und Datenträger stehen in eigenen Bereichen.
+Den Root-Zugang nach seinem Namen auswählen; es muss keine Referenz-ID kopiert werden.
+FQDN und Management-IP können später vom zugeordneten Server stammen.
+
+Für Server mit immer genau einer Zielplatte **ZFS**, **RAID0 / Einzelplatte**
+und die automatische Plattenwahl verwenden. Dabei werden alle vom Installer
+erkannten Zielplatten verwendet; die Anwendung zählt die tatsächlich eingebauten
+Platten nicht. Für eine ausdrückliche Auswahl stehen Seriennummern und WWNs zur
+Verfügung. Erweiterte ZFS- und LVM-Optionen besitzen Zahlen- und Auswahlfelder.
+
+Eine neue Version übernimmt die vorhandenen Werte. Leere optionale Felder
+entfernen die jeweilige Vorgabe. **Entwurf speichern** speichert das Profil;
+die Veröffentlichung mit Testnachweis bleibt ein eigener Schritt.
+
+## Module und Nachinstallation
+
+Ein Modul aus dem Vorlagenkatalog übernehmen oder im Modulformular eine Vorlage
+auswählen. Eigene vorhandene Skripte lassen sich als Datei hochladen; die
+Skriptansicht ist schreibgeschützt. Parameter werden als Felder mit Typ,
+Beschreibung, Pflichtstatus, Vorgaben und Grenzen angelegt.
+
+Im Postinstallationsprofil einen Schritt hinzufügen und eine veröffentlichte
+Modulversion nach Namen auswählen. Die Parameter erscheinen als passende
+Eingaben, Listen, Checkboxen und Auswahllisten. Benötigte Geheimnisse werden
+ebenfalls nach Namen zugeordnet. Schritte können entfernt oder mit den
+Pfeiltasten umgeordnet werden. Die bestehenden Schrittkennungen bleiben dabei
+erhalten. Mindestens ein verpflichtender Prüfschritt ist erforderlich.
+
+## Server erfassen
+
+Hostname und Standort eintragen, Hardware-Kennungen über getrennte Typ- und
+Wertfelder ergänzen und Profile sowie Medium auswählen. Für mehrere Server
+**Mehrere Server erfassen** verwenden: Jede Karte enthält einen Server;
+Standort und Profilzuordnungen gelten gemeinsam.
+
+Abweichende Einstellungen eines Hosts stehen in einem aufklappbaren Bereich.
+Nicht aktivierte Bereiche übernehmen die Profilwerte. Vor einer Installation
+die aufgelöste Vorschau prüfen und die Freigabe erteilen.
+
+Die Erstellung des eigentlichen ISO-Mediums erfolgt weiterhin über den offiziellen
+Assistant auf einer Build-Maschine; siehe [ISO-Vorbereitung](iso-preparation.md).
diff --git a/provisioner/__init__.py b/provisioner/__init__.py
index 69474f2..b293ff7 100644
--- a/provisioner/__init__.py
+++ b/provisioner/__init__.py
@@ -1,3 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
-__version__ = "0.9.2"
+__version__ = "0.9.3"
diff --git a/provisioner/app.py b/provisioner/app.py
index 8c70c1a..636c3bb 100644
--- a/provisioner/app.py
+++ b/provisioner/app.py
@@ -390,15 +390,16 @@ def create_app(settings: Settings | None = None):
return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection)
@app.get("/api/v1/secrets")
- def secrets_list(user=Depends(roles())):
+ def secrets_list(user=Depends(roles("author","operator"))):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")]
@app.post("/api/v1/secrets",status_code=201)
def create_secret(payload:SecretCreate,user=Depends(roles())):
+ value = security.hash_installer_password(payload.value) if payload.kind == "root_password" else payload.value
with db.connection(write=True) as connection:
secret_id = new_id("secret")
- connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso()))
+ connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(value),now_iso()))
audit(connection,user["id"],"secret.created",secret_id)
return {"id":secret_id,"name":payload.name}
diff --git a/provisioner/models.py b/provisioner/models.py
index ffe2f82..091d84e 100644
--- a/provisioner/models.py
+++ b/provisioner/models.py
@@ -1,9 +1,9 @@
-from typing import Any, Literal
+from typing import Annotated, Any, Literal
from ipaddress import ip_interface
import re
import uuid
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationInfo, field_validator
class Model(BaseModel):
@@ -156,7 +156,19 @@ class UserCreate(Model):
class SecretCreate(Model):
name: str = Field(min_length=1, max_length=120)
- value: str = Field(min_length=1, max_length=16384)
+ kind: Literal["value", "root_password"] = "value"
+ value: Annotated[str, StringConstraints(strip_whitespace=False)] = Field(min_length=1, max_length=16384)
+
+ @field_validator("value", mode="before")
+ @classmethod
+ def secret_value(cls, value, info: ValidationInfo):
+ if not isinstance(value, str):
+ return value
+ if info.data.get("kind") == "root_password":
+ if not 12 <= len(value) <= 1024 or "\x00" in value:
+ raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
+ return value
+ return value.strip()
class Enroll(Model):
diff --git a/provisioner/security.py b/provisioner/security.py
index 6bec862..3cb07ff 100644
--- a/provisioner/security.py
+++ b/provisioner/security.py
@@ -8,6 +8,13 @@ import re
import secrets
from cryptography.fernet import Fernet
+from passlib.hash import sha512_crypt
+
+
+# The installer requires a Linux crypt hash. The builtin backend also works
+# on Python 3.13+, where the standard-library crypt module no longer exists.
+_installer_password_hash = sha512_crypt.using(rounds=656000)
+_installer_password_hash.set_backend("builtin")
def canonical(value):
@@ -43,6 +50,12 @@ class Security:
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode()
+ @staticmethod
+ def hash_installer_password(password):
+ if not 12 <= len(password) <= 1024 or "\x00" in password:
+ raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
+ return _installer_password_hash.hash(password)
+
@staticmethod
def verify_password(password, stored):
try:
diff --git a/provisioner/static/app.js b/provisioner/static/app.js
index 4264aa3..3c24220 100644
--- a/provisioner/static/app.js
+++ b/provisioner/static/app.js
@@ -128,12 +128,12 @@ function dashboard(data) {
}
function hostsPage(hosts, discoveries=[]) {
const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase()))));
- return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}`+(unassigned.length?`
Entdeckte Hardware ${unassigned.length}
Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.
`))}`:'');
+ return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Mehrere Server erfassen','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}`+(unassigned.length?`
Entdeckte Hardware ${unassigned.length}
Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.
${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')}
${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')}
`).join('')}`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
+ if(state.runTab==='steps') content=steps.length?`${steps.map((s,i)=>`
`).join('')}`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
if(state.runTab==='events') content=eventList(events,'Noch keine Laufereignisse');
if(state.runTab==='logs') content=logs.length?`
`: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')+`
`;
}
function mediaPage(records, groups=[]) {
return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+
@@ -180,7 +180,7 @@ function closeModal() {modal.close();modalSubmit=null;document.getElementById('m
function field(name,label,value='',options={}) {
const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`;
let input;
- if(options.type==='textarea'||options.type==='json') input=``;
+ if(options.type==='textarea') input=``;
else if(options.type==='select') input=``;
else if(options.type==='checkbox') return ``;
else input=``;
@@ -189,24 +189,10 @@ function field(name,label,value='',options={}) {
function form(fields,label='Speichern',intro='') {
return `${intro}`;
}
-function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}}
const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean);
const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))];
-async function hostForm(existing=null, discovery=null) {
- const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
- const h=existing||discovery||{};
- const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'});
- showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{
- const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};});
- const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')};
- if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;}
- await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh();
- },'INVENTAR');
-}
-function hostImportForm() {
- const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}];
- showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT');
-}
+async function hostForm(existing=null, discovery=null) { return graphicalHostForm(existing,discovery); }
+async function hostImportForm() { return graphicalHostImport(); }
async function moduleCatalog() {
const catalog=arr(await api('/modules/builtin'));
showModal('Basismodul als Entwurf übernehmen',`
Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.
${catalog.map(m=>`
${svg('code')}
${esc(m.name)}
${esc(m.description)}
`).join('')}`,null,'MODULVORLAGEN');
@@ -215,7 +201,7 @@ async function moduleCatalog() {
function previewContent(preview) {
const p=preview||{}, warnings=arr(p.warnings);
const network=p.resolved?.network||{};
- return `${warnings.length?`
Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: ${esc(m.id)}`).join(' '):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}
`;}
- const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
- showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{
- await api('/profiles',{method:'POST',body:{name:data.get('name'),kind,target_builds:split(data.get('target_builds')),values:parseJSON(data,'values'),steps:install?[]:parseJSON(data,'steps',[]),reason:data.get('reason')||'',...(!install?{reboot_budget:Number(data.get('reboot_budget'))}:{})}});
- closeModal();toast('Profilentwurf gespeichert. Eine Veröffentlichung benötigt einen Testnachweis.');await refresh();
- },install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL');
-}
-const moduleExample = '#!/usr/bin/env bash\nset -euo pipefail\n\ncheck() {\n systemctl is-active --quiet pveproxy\n}\n\napply() {\n # Nur erforderliche, geprüfte Änderungen ausführen.\n return 0\n}\n\nverify() {\n systemctl is-active --quiet pveproxy\n}\n\ncase "${1:-}" in\n check) check ;;\n apply) apply ;;\n verify) verify ;;\n *) echo "Usage: $0 {check|apply|verify}" >&2; exit 2 ;;\nesac\n';
-function moduleForm(existing=null) {
- const m=existing||{};
- const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(m.target_builds).join(', '),{required:true,placeholder:'z. B. 9.1-1'})+field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds||300,{type:'number',required:true,min:1,max:7200})+field('source','Bash-Quelltext',m.source||moduleExample,{type:'json',required:true,full:true,rows:17,hint:'Aufruf: bash modul.sh check|apply|verify parameter.json. Parameter werden als JSON-Datei übergeben.'})+field('parameters_schema','Parameterschema (JSON Schema)',m.parameters_schema||{type:'object',properties:{},additionalProperties:false},{type:'json',full:true,rows:6})+field('dependencies','Abhängige Modulnamen',arr(m.dependencies).join(', '),{full:true,placeholder:'Optional: exakte Modulnamen, durch Komma getrennt'})+field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe||false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit im Test nachgewiesen wurde.'})+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Modulstand'});
- showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{
- await api('/modules',{method:'POST',body:{name:data.get('name'),source:data.get('source'),parameters_schema:parseJSON(data,'parameters_schema'),dependencies:split(data.get('dependencies')),target_builds:split(data.get('target_builds')),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh();
- },'VERSIONIERTE SKRIPTMODULE');
-}
+async function profileForm(kind, existing=null) { return graphicalProfileForm(kind,existing); }
+async function moduleForm(existing=null) { return graphicalModuleForm(existing); }
async function publishObject(type, id) {
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
const intro=`
${esc(item.name)} · Version ${esc(item.version)} Veröffentlichten Inhalt können Sie nicht mehr ändern. Dokumentieren Sie den praktischen Test auf einem passenden Testhost. Im Vieraugenmodus muss eine andere Person den Entwurf veröffentlichen.
`;
@@ -260,10 +224,10 @@ async function publishObject(type, id) {
},'VERÖFFENTLICHUNG');
}
function inspectProfile(p) {
- showModal(p.name,`
Version
v${esc(p.version)} ${badge(p.status)}
Zielbuilds
${esc((p.target_builds||[]).join(', ')||'–')}
Profil-ID
${esc(p.id)}
Digest
${esc(p.digest||'Noch nicht veröffentlicht')}
${esc(json({values:p.values,steps:p.steps}))}
${canAuthor()?``:''}
`,null,'PROFILDETAILS');
+ showModal(p.name,`
Version
v${esc(p.version)} ${badge(p.status)}
Zielbuilds
${esc((p.target_builds||[]).join(', ')||'–')}
Profil-ID
${esc(p.id)}
Digest
${esc(p.digest||'Noch nicht veröffentlicht')}
${dataView({values:p.values,steps:p.steps})}
${canAuthor()?``:''}
`,null,'PROFILDETAILS');
}
function inspectModule(m) {
- showModal(m.name,`
Version / Status
v${esc(m.version)} ${badge(m.status)}
Modul-ID
${esc(m.id)}
Zielbuilds
${esc((m.target_builds||[]).join(', '))}
Digest
${esc(m.digest||'Noch nicht veröffentlicht')}
Testnachweis
${esc(m.test_evidence||'Noch nicht hinterlegt')}
${m.source?`
${esc(m.source)}
`:'
Der Quelltext ist für Skriptautoren und Administratoren sichtbar.
`);
+ const fields=field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Zugang · PVE Berlin'})+field('kind','Verwendung','root_password',{type:'select',full:true,options:[{value:'root_password',label:'Root-Passwort für die Proxmox-Installation'},{value:'value',label:'Anderes Geheimnis oder vorhandener Passwort-Hash'}]})+field('value','Passwort / Geheimnis','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für ein neues Root-Passwort mindestens 12 Zeichen eingeben. Das Tool bereitet es für den Installer auf.'});
+ showModal('Zugang hinterlegen',form(fields,'Verschlüsselt speichern','
Den gespeicherten Zugang anschließend im Profil oder Schritt nach seinem Namen auswählen.
${field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})}${field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})}${field('management_ip','Management-IP mit Präfix',h.management_ip,{placeholder:'192.0.2.10/24',hint:'Zum Erfassen optional; vor der Installation erforderlich.',full:true})}${dataEditor('host-tags',arr(h.tags),{label:'Tags',schema:{type:'array',items:{type:'string'}}})}
${identitiesEditor(arr(h.identities))}
Installation zuweisen
${field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),h.installation_profile_id),'Noch nicht zuweisen')})}${field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),h.postinstall_profile_id),'Noch nicht zuweisen')})}${field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(withMissing(arr(isos),h.iso_id),'Noch nicht zuweisen')})}
Abweichende Einstellungen für diesen Server
Ohne Abweichung gelten die Werte des zugewiesenen Profils.
${field('import_fqdn','Vollständiger Hostname','',{required:true,placeholder:'pve-01.example.net'})}${field('import_ip','Management-IP mit Präfix','',{placeholder:'192.0.2.10/24'})}${identitiesEditor()}
`;
+}
+async function graphicalHostImport() {
+ const [profiles,isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
+ const fields=field('site','Gemeinsamer Standort','',{required:true,full:true})+field('installation_profile_id','Installationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium','',{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+`
${importHostRow()}
`;
+ showModal('Mehrere Server erfassen',form(fields,'Server anlegen'),async data=>{
+ const rows=[...modal.querySelectorAll('[data-import-host]')];
+ if(!rows.length||rows.length>100)throw new Error('Bitte zwischen einem und 100 Servern erfassen.');
+ const common={site:data.get('site'),installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null};
+ const hosts=rows.map(row=>({...common,fqdn:row.querySelector('[name="import_fqdn"]').value,management_ip:row.querySelector('[name="import_ip"]').value||null,identities:readIdentities(row)}));
+ await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server angelegt.`);await refresh();
+ },'SERVERINVENTAR');
+ wireHostRows(modal);
+}
diff --git a/provisioner/static/installation-form.js b/provisioner/static/installation-form.js
new file mode 100644
index 0000000..f45f066
--- /dev/null
+++ b/provisioner/static/installation-form.js
@@ -0,0 +1,296 @@
+/* Graphical installation settings. The server validates the resolved host configuration. */
+const installationForm = (() => {
+ const clone = value => JSON.parse(JSON.stringify(value));
+ const object = (value, label) => {
+ if (value === undefined) return {};
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: Erwartet werden benannte Einstellungen.`);
+ return value;
+ };
+ const keyboards = [
+ ['de','Deutsch'],['de-ch','Deutsch (Schweiz)'],['dk','Dänisch'],['en-gb','Englisch (Großbritannien)'],
+ ['en-us','Englisch (USA)'],['es','Spanisch'],['fi','Finnisch'],['fr','Französisch'],
+ ['fr-be','Französisch (Belgien)'],['fr-ca','Französisch (Kanada)'],['fr-ch','Französisch (Schweiz)'],
+ ['hu','Ungarisch'],['is','Isländisch'],['it','Italienisch'],['jp','Japanisch'],['lt','Litauisch'],
+ ['mk','Mazedonisch'],['nl','Niederländisch'],['no','Norwegisch'],['pl','Polnisch'],
+ ['pt','Portugiesisch'],['pt-br','Portugiesisch (Brasilien)'],['se','Schwedisch'],['si','Slowenisch'],['tr','Türkisch']
+ ];
+ const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
+ const countries = (() => {
+ const names = typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['de'],{type:'region'}) : null;
+ return countryCodes.map(code => [code.toLowerCase(),names?.of(code) || ({DE:'Deutschland',AT:'Österreich',CH:'Schweiz'}[code] || code)])
+ .sort((a,b) => a[1].localeCompare(b[1],'de'));
+ })();
+ const timezones = (() => {
+ const regions = {Africa:'Afrika',America:'Amerika',Antarctica:'Antarktis',Arctic:'Arktis',Asia:'Asien',Atlantic:'Atlantik',Australia:'Australien',Europe:'Europa',Indian:'Indischer Ozean',Pacific:'Pazifik'};
+ const supported = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
+ return [...new Set(['Europe/Berlin','UTC',...supported])].map(zone => [zone,zone === 'UTC' ? 'UTC (koordinierte Weltzeit)' : zone.split('/').map((part,index) => index === 0 ? regions[part] || part : part.replaceAll('_',' ')).join(' / ')])
+ .sort((a,b) => a[1].localeCompare(b[1],'de'));
+ })();
+ const interfaces = [
+ ['ID_NET_NAME_MAC','Name aus MAC-Adresse'], ['ID_NET_NAME_PATH','Name aus Gerätepfad'],
+ ['ID_NET_NAME_SLOT','Name des Steckplatzes'], ['ID_NET_NAME_ONBOARD','Name der Onboard-Schnittstelle'],
+ ['INTERFACE','Schnittstellenname'], ['ID_PATH','Gerätepfad'], ['ID_NET_DRIVER','Treiber']
+ ];
+ const raids = [['raid0','RAID0 / Einzelplatte'],['raid1','RAID1 / Spiegel'],['raid10','RAID10'],['raidz-1','RAIDZ1'],['raidz-2','RAIDZ2'],['raidz-3','RAIDZ3']];
+ const zfsNumbers = [['ashift','Sektorgröße (ashift)',9,16,true],['arc-max','Maximaler ARC in MiB',64,1048576,true],['copies','Datenkopien',1,3,true],['hdsize','Verwendete Plattengröße in GiB',2,1000000,false]];
+ const lvmNumbers = [['hdsize','Verwendete Plattengröße in GiB',2],['swapsize','Swap-Größe in GiB',0],['maxroot','Maximale Root-Größe in GiB',2],['maxvz','Maximale Daten-Größe in GiB',0],['minfree','Freier Platz in GiB',0]];
+ const optionsFor = (entries, current, blank = 'Nicht festlegen') => {
+ const items = entries.map(item => Array.isArray(item) ? {value:item[0],label:item[1]} : {value:item,label:item});
+ if (current !== undefined && current !== '' && !items.some(item => String(item.value) === String(current))) items.push({value:current,label:`Bestehender Wert: ${current}`});
+ return [{value:'',label:blank}, ...items];
+ };
+ function root(container, prefix) {
+ const nodes = [...(container.matches?.('[data-installation-editor]') ? [container] : []), ...container.querySelectorAll('[data-installation-editor]')];
+ const found = nodes.find(node => node.dataset.installationEditor === prefix);
+ if (!found) throw new Error('Das Installationsformular wurde nicht gefunden. Bitte erneut öffnen.');
+ return found;
+ }
+ function text(value, label) {
+ if (value === undefined) return '';
+ if (typeof value !== 'string') throw new Error(`${label}: Der gespeicherte Wert muss Text sein.`);
+ return value;
+ }
+ function strings(value, label) {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}: Die gespeicherten Einträge müssen Text sein.`);
+ return value;
+ }
+ function numeric(value, label) {
+ if (value === undefined) return '';
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label}: Der gespeicherte Wert muss eine Zahl sein.`);
+ return value;
+ }
+ function networkRow(prefix, key = 'ID_NET_NAME_MAC', value = '') {
+ const mac = key === 'ID_NET_NAME_MAC' && (!value || /^enx[0-9a-f]{12}$/.test(value));
+ const known = interfaces.some(item => item[0] === key);
+ const selected = mac ? '__mac' : known ? key : '__custom';
+ if (mac && value) value = value.slice(3).match(/../g).join(':');
+ return `
${field(`${prefix}.interface-custom`,'Name des anderen Merkmals',known?'':key,{full:true})}
+
+
`;
+ }
+ function render(values = {}, options = {}) {
+ const prefix = options.prefix || 'installation', override = Boolean(options.override);
+ values = clone(object(values,'Installationskonfiguration'));
+ const fresh = !override && Object.keys(values).length === 0;
+ if (fresh) values = {global:{keyboard:'de',country:'de',timezone:'Europe/Berlin'},network:{source:'from-answer'},disk_setup:{filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}}};
+ const global = object(values.global,'Allgemeine Einstellungen'), network = object(values.network,'Managementnetz');
+ const disks = object(values.disk_setup,'Datenträger'), zfs = object(disks.zfs,'ZFS'), lvm = object(disks.lvm,'LVM');
+ const netFilter = object(network.filter,'Schnittstellenmerkmale'), diskFilter = object(disks.filter,'Datenträgerfilter');
+ if (Object.keys(diskFilter).length > 1) throw new Error('Die Datenträgerauswahl enthält mehrere Filter. Es wird genau ein Seriennummern- oder WWN-Filter unterstützt.');
+ if (global['reboot-on-error'] !== undefined && typeof global['reboot-on-error'] !== 'boolean') throw new Error('Neustart bei Fehler: Der gespeicherte Wert muss Ja oder Nein sein.');
+ const input = (name,label,value,settings={}) => field(`${prefix}.${name}`,label,text(value,label),{...settings});
+ const select = (name,label,value,entries,settings={}) => input(name,label,value,{type:'select',options:optionsFor(entries,value,override?'Vom Profil übernehmen':'Nicht festlegen'),...settings});
+ const number = (name,label,value,min,max,integer=false) => field(`${prefix}.${name}`,label,numeric(value,label),{type:'number',min,max,hint:'Leer lassen: vorhandene Vorgabe verwenden.'}).replace('type="number"',`type="number" step="${integer?'1':'any'}"`);
+ const section = (name,title,body) => `
+ ${override?field(`${prefix}.enable.${name}`,`${title} für diesen Host festlegen`,Object.hasOwn(values,name),{type:'checkbox',full:true}):`
${esc(title)}
`}
+
${body}
`;
+ const required = !override;
+ const secretId = text(values.root_secret_id,'Root-Geheimnis');
+ const secrets = (options.secrets || []).map(secret => [secret.id, secret.name || secret.id]);
+ if (secretId && !secrets.some(secret => secret[0] === secretId)) secrets.push([secretId,`Bisherige Referenz (${secretId})`]);
+ const diskMode = disks.selection !== undefined ? disks.selection : Object.keys(disks).some(key => ['filter','filter_match','expected_serials','expected_count'].includes(key)) ? 'filtered' : '';
+ const diskKey = Object.keys(diskFilter)[0] || (override ? '' : 'ID_SERIAL_SHORT');
+ const diskPattern = diskKey ? diskFilter[diskKey] : undefined;
+ const networkRows = Object.entries(netFilter).map(([key,value]) => networkRow(prefix,key,value)).join('') || (override?'':networkRow(prefix));
+ const reboot = global['reboot-on-error'] === undefined ? '' : String(global['reboot-on-error']);
+ const general = select('global.keyboard','Tastatur',global.keyboard,keyboards,{required})
+ + select('global.country','Land',global.country,countries,{required})
+ + select('global.timezone','Zeitzone',global.timezone,timezones,{required})
+ + input('global.mailto','E-Mail für Systemmeldungen',global.mailto,{required,type:'email'})
+ + input('global.fqdn','Hostname-Vorgabe (optional)',global.fqdn,{hint:'Der tatsächlich zugewiesene Server liefert seinen vollständigen Hostnamen.'})
+ + select('global.reboot-on-error','Bei Installationsfehler neu starten',reboot,[['false','Nein'],['true','Ja']])
+ + dataEditor(`${prefix}.global.root-ssh-keys`,strings(global['root-ssh-keys'],'SSH-Schlüssel'),{label:'Öffentliche SSH-Schlüssel für root',schema:{type:'array',description:'Für jeden öffentlichen Schlüssel einen Eintrag hinzufügen. Keine privaten Schlüssel hinterlegen.',items:{type:'string',title:'Öffentlicher SSH-Schlüssel',minLength:1}}});
+ const networking = select('network.source','Netzwerkkonfiguration',network.source,[['from-answer','Festes Managementnetz aus diesem Profil']],{required})
+ + input('network.gateway','Standardgateway',network.gateway,{required})
+ + input('network.dns','DNS-Server',network.dns,{required})
+ + input('network.cidr','Management-IP mit Präfix (optional)',network.cidr,{hint:'Die tatsächliche IP mit Präfix kommt aus dem Serverinventar.'})
+ + `
Management-Schnittstelle
Ein oder mehrere Merkmale der gewünschten Netzwerkkarte. Der Installer verwendet diese zur Auswahl.
${networkRows}
`;
+ const filtering = select('disk.filter.key','Datenträger erkennen über',diskKey,[['ID_SERIAL_SHORT','Seriennummer'],['ID_SERIAL','Vollständige Serienkennung'],['ID_WWN','WWN']],{required})
+ + input('disk.filter.value','Seriennummer oder Suchmuster',diskPattern,{required,hint:'Ein konkreter Wert oder ein Muster für die unten bestätigten Kennungen.'})
+ + select('disk.filter_match','Filterverknüpfung',disks.filter_match,[['all','Alle Merkmale'],['any','Mindestens ein Merkmal']])
+ + number('disk.expected_count','Anzahl bestätigter Datenträger',disks.expected_count,1,16,true)
+ + dataEditor(`${prefix}.disk.expected_serials`,strings(disks.expected_serials,'Datenträgerkennungen'),{label:'Bestätigte Seriennummern oder WWNs',schema:{type:'array',description:'Jeden bestätigten Datenträger als eigenen Eintrag hinzufügen. Die Anzahl wird aus der Liste übernommen, wenn das Anzahlfeld leer bleibt; die tatsächliche Hardware wird dadurch nicht gezählt.',items:{type:'string',title:'Seriennummer oder WWN',minLength:1}}});
+ const zfsFields = select('disk.zfs.raid','ZFS-Verbund',zfs.raid,raids,{required})
+ + zfsNumbers.map(([key,label,min,max,integer]) => number(`disk.zfs.${key}`,label,zfs[key],min,max,integer)).join('')
+ + select('disk.zfs.checksum','Prüfsummen',zfs.checksum,[['on','Standard'],['fletcher4','Fletcher4'],['sha256','SHA-256']])
+ + select('disk.zfs.compress','Kompression',zfs.compress,[['on','Standard'],['off','Aus'],['lzjb','LZJB'],['lz4','LZ4'],['zle','ZLE'],['gzip','Gzip'],['zstd','Zstandard']]);
+ const storage = select('disk.filesystem','Dateisystem',disks.filesystem,[['zfs','ZFS'],['ext4','ext4 / LVM'],['xfs','XFS / LVM']],{required})
+ + select('disk.mode','Datenträgerauswahl',diskMode,[['all','Automatisch – Server mit einer Zielplatte'],['filtered','Über Seriennummer oder WWN auswählen']],{required})
+ + `
Für Server mit genau einer Zielplatte: Es werden alle vom Installer erkannten Zielplatten verwendet. Seriennummer und Gerätename sind nicht erforderlich. Die tatsächliche Anzahl wird nicht geprüft.
`
+ + `
${filtering}
`
+ + input('disk.inventory_evidence','Referenz zur Hardwareprüfung',disks.inventory_evidence,{full:true,hint:'Bei Auswahl per Seriennummer oder WWN erforderlich; bei automatischer Auswahl optional.'})
+ + `
`;
+ }
+ function read(container, prefix = 'installation', previousValues = {}) {
+ const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState);
+ const values = clone(object(previousValues,'Installationskonfiguration'));
+ const before = state.values;
+ const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`);
+ const value = name => (control(name)?.value || '').trim();
+ const enabled = name => !state.override || Boolean(control(`enable.${name}`)?.checked);
+ const assign = (target,key,next) => {if (next === undefined) delete target[key]; else target[key] = next;};
+ const string = name => value(name) || undefined;
+ const numericValue = (name,label,min,max,integer=false) => {
+ const node = control(name), raw = value(name);
+ if (node?.validity?.badInput) throw new Error(`${label}: Bitte eine gültige Zahl eingeben.`);
+ if (!raw) return undefined;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max || (integer && !Number.isInteger(parsed))) throw new Error(`${label}: ${integer?'Eine ganze Zahl':'Eine Zahl'} zwischen ${min} und ${max} eingeben.`);
+ if (name.startsWith('disk.lvm.') && parsed >= 1000000) throw new Error(`${label}: Die Größe muss kleiner als 1000000 GiB sein.`);
+ return parsed;
+ };
+ const list = (name,previous) => {
+ const result = readDataEditor(editor,`${prefix}.${name}`);
+ if (!Array.isArray(result) || result.some(item => typeof item !== 'string' || !item.trim())) throw new Error(`${name.includes('ssh')?'SSH-Schlüssel':'Datenträgerkennungen'}: Jeden Eintrag ausfüllen oder den leeren Eintrag entfernen.`);
+ return result.length || Array.isArray(previous) && previous.length === 0 ? result : undefined;
+ };
+ const base = name => clone(object(Object.hasOwn(values,name)?values[name]:before[name],name));
+ if (enabled('global')) {
+ const global = base('global');
+ for (const key of ['keyboard','country','timezone','mailto','fqdn']) assign(global,key,string(`global.${key}`));
+ const reboot = value('global.reboot-on-error');
+ if (reboot && !['true','false'].includes(reboot)) throw new Error('Neustart bei Fehler: Bitte Ja, Nein oder Übernehmen wählen.');
+ assign(global,'reboot-on-error',reboot ? reboot === 'true' : undefined);
+ assign(global,'root-ssh-keys',list('global.root-ssh-keys',before.global?.['root-ssh-keys']));
+ values.global = global;
+ } else delete values.global;
+ if (enabled('root_secret_id')) assign(values,'root_secret_id',string('root_secret_id'));
+ else delete values.root_secret_id;
+ if (enabled('network')) {
+ const network = base('network');
+ for (const key of ['source','gateway','dns','cidr']) assign(network,key,string(`network.${key}`));
+ const filter = Object.create(null);
+ for (const row of editor.querySelectorAll('[data-installation-network-row]')) {
+ const input = suffix => [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${suffix}`)?.value.trim() || '';
+ const selected = input('interface-key'), key = selected === '__custom' ? input('interface-custom') : selected === '__mac' ? 'ID_NET_NAME_MAC' : selected;
+ let expected = input('interface-value');
+ if (!expected && selected !== '__custom') continue;
+ if (!key || !expected) throw new Error('Management-Schnittstelle: Merkmal und erwarteten Wert vollständig ausfüllen.');
+ if (selected === '__mac') {
+ const mac = expected.replace(/[:-]/g,'').toLowerCase();
+ if (!/^[0-9a-f]{12}$/.test(mac)) throw new Error('Management-Schnittstelle: Eine gültige MAC-Adresse wie 02:00:00:00:00:01 eingeben.');
+ expected = `enx${mac}`;
+ }
+ if (['__proto__','prototype','constructor'].includes(key) || Object.hasOwn(filter,key)) throw new Error('Management-Schnittstelle: Jedes gültige Merkmal darf nur einmal vorkommen.');
+ filter[key] = expected;
+ }
+ const keepEmptyFilter = before.network?.filter && Object.keys(before.network.filter).length === 0;
+ if (!state.override && !Object.keys(filter).length) throw new Error('Bitte die MAC-Adresse oder ein anderes Merkmal der Management-Schnittstelle angeben.');
+ assign(network,'filter',Object.keys(filter).length || keepEmptyFilter ? {...filter} : undefined);
+ values.network = network;
+ } else delete values.network;
+ if (enabled('disk_setup')) {
+ const disks = base('disk_setup'), filesystem = value('disk.filesystem'), mode = value('disk.mode');
+ if (filesystem && !['zfs','ext4','xfs'].includes(filesystem)) throw new Error('Bitte ZFS, ext4 oder XFS auswählen.');
+ if (mode && !['all','filtered'].includes(mode)) throw new Error('Bitte eine gültige Datenträgerauswahl treffen.');
+ assign(disks,'filesystem',filesystem || undefined);
+ assign(disks,'inventory_evidence',string('disk.inventory_evidence'));
+ if (mode === 'all') {
+ const raid = value('disk.zfs.raid');
+ if ((filesystem && filesystem !== 'zfs') || (raid && raid !== 'raid0') || (!state.override && (!filesystem || !raid))) throw new Error('Die automatische Datenträgerauswahl benötigt ZFS mit RAID0.');
+ disks.selection = 'all';
+ for (const key of ['filter','filter_match','expected_count','expected_serials']) delete disks[key];
+ } else {
+ delete disks.selection;
+ const key = value('disk.filter.key'), pattern = value('disk.filter.value');
+ if ((key && pattern) && !['ID_SERIAL','ID_SERIAL_SHORT','ID_WWN'].includes(key)) throw new Error('Bitte Seriennummer oder WWN zur Datenträgerauswahl verwenden.');
+ if (mode === 'filtered' && !state.override && (!key || !pattern)) throw new Error('Bitte ein Datenträgermerkmal und einen Seriennummern- oder WWN-Filter eingeben.');
+ assign(disks,'filter',key && pattern ? {[key]:pattern} : undefined);
+ assign(disks,'filter_match',string('disk.filter_match'));
+ const serials = list('disk.expected_serials',before.disk_setup?.expected_serials);
+ if (mode === 'filtered' && !state.override && !serials?.length) throw new Error('Bitte mindestens eine bestätigte Datenträgerkennung hinzufügen.');
+ if (serials && (serials.length > 16 || new Set(serials).size !== serials.length || serials.some(item => /[*?\[\]]/.test(item)))) throw new Error('Datenträgerkennungen: Höchstens 16 unterschiedliche, konkrete Seriennummern oder WWNs eingeben.');
+ const count = numericValue('disk.expected_count','Anzahl bestätigter Datenträger',1,16,true);
+ if (count !== undefined && serials && count !== serials.length) throw new Error('Die Anzahl muss zur Liste der bestätigten Datenträger passen.');
+ assign(disks,'expected_serials',serials);
+ assign(disks,'expected_count',count ?? (!state.override && serials?.length ? serials.length : undefined));
+ if (disks.filter_match && !['all','any'].includes(disks.filter_match)) throw new Error('Bitte eine gültige Filterverknüpfung wählen.');
+ if (disks.inventory_evidence && disks.inventory_evidence.length < 5) throw new Error('Die Referenz zur Hardwareprüfung muss mindestens fünf Zeichen enthalten.');
+ }
+ if (filesystem === 'zfs' || !filesystem && (before.disk_setup?.zfs || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.zfs.`) && node.value))) {
+ const zfs = clone(object(disks.zfs,'ZFS'));
+ assign(zfs,'raid',string('disk.zfs.raid'));
+ for (const [key,label,min,max,integer] of zfsNumbers) assign(zfs,key,numericValue(`disk.zfs.${key}`,label,min,max,integer));
+ for (const key of ['checksum','compress']) assign(zfs,key,string(`disk.zfs.${key}`));
+ if (zfs.raid && !raids.some(([raid]) => raid === zfs.raid)) throw new Error('Bitte einen unterstützten ZFS-Verbund auswählen.');
+ if (zfs.checksum && !['on','fletcher4','sha256'].includes(zfs.checksum)) throw new Error('Bitte eine unterstützte ZFS-Prüfsumme auswählen.');
+ if (zfs.compress && !['on','off','lzjb','lz4','zle','gzip','zstd'].includes(zfs.compress)) throw new Error('Bitte eine unterstützte ZFS-Kompression auswählen.');
+ const count = disks.expected_serials?.length, minimum = {raid0:1,raid1:2,raid10:4,'raidz-1':3,'raidz-2':4,'raidz-3':5};
+ if (mode === 'filtered' && count && zfs.raid && (count < minimum[zfs.raid] || zfs.raid === 'raid10' && count % 2)) throw new Error('Die bestätigte Datenträgeranzahl passt nicht zum gewählten ZFS-Verbund.');
+ disks.zfs = zfs;
+ if (filesystem) delete disks.lvm;
+ } else if (filesystem) delete disks.zfs;
+ if (['ext4','xfs'].includes(filesystem) || !filesystem && (before.disk_setup?.lvm || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.lvm.`) && node.value))) {
+ if (disks.expected_serials?.length > 1) throw new Error('ext4 und XFS benötigen genau einen bestätigten Systemdatenträger.');
+ const lvm = clone(object(disks.lvm,'LVM'));
+ for (const [key,label,min] of lvmNumbers) assign(lvm,key,numericValue(`disk.lvm.${key}`,label,min,1000000));
+ if (Object.keys(lvm).length || Object.hasOwn(before.disk_setup || {},'lvm')) disks.lvm = lvm;
+ else delete disks.lvm;
+ } else if (filesystem) delete disks.lvm;
+ values.disk_setup = disks;
+ } else delete values.disk_setup;
+ if (state.override) for (const key of ['global','network','disk_setup']) {
+ if (values[key] && !Object.keys(values[key]).length && (!Object.hasOwn(before,key) || Object.keys(before[key]).length)) delete values[key];
+ }
+ return values;
+ }
+ function wire(container, prefix = 'installation') {
+ const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState);
+ if (editor.dataset.installationWired) return;
+ editor.dataset.installationWired = 'true';
+ wireDataEditors(editor);
+ const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`);
+ const visible = (node,show) => {node.hidden = !show; for (const item of node.querySelectorAll('input,select,textarea,button')) item.disabled = !show;};
+ function refresh() {
+ for (const section of editor.querySelectorAll('[data-installation-section]')) {
+ const enabled = !state.override || control(`enable.${section.dataset.installationSection}`).checked;
+ visible(section.querySelector('[data-installation-section-body]'),enabled);
+ }
+ const diskEnabled = !state.override || control('enable.disk_setup').checked;
+ const filesystem = control('disk.filesystem').value, mode = control('disk.mode').value;
+ visible(editor.querySelector('[data-installation-filter-fields]'),diskEnabled && mode !== 'all');
+ visible(editor.querySelector('[data-installation-zfs-fields]'),diskEnabled && (!filesystem || filesystem === 'zfs'));
+ visible(editor.querySelector('[data-installation-lvm-fields]'),diskEnabled && (!filesystem || ['ext4','xfs'].includes(filesystem)));
+ editor.querySelector('[data-installation-all-note]').hidden = !diskEnabled || mode !== 'all';
+ control('disk.inventory_evidence').required = !state.override && mode === 'filtered';
+ for (const row of editor.querySelectorAll('[data-installation-network-row]')) {
+ const select = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-key`);
+ const expected = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-value`);
+ expected.placeholder = select.value === '__mac' ? '02:00:00:00:00:01' : 'Erwarteter Merkmalswert';
+ visible(row.querySelector('[data-installation-custom-key]'),(!state.override || control('enable.network').checked) && select.value === '__custom');
+ }
+ }
+ editor.addEventListener('change', event => {
+ const name = event.target.name;
+ if (name === `${prefix}.disk.mode` && event.target.value === 'all') {
+ control('disk.filesystem').value = 'zfs';
+ control('disk.zfs.raid').value = 'raid0';
+ }
+ if (name === `${prefix}.disk.filesystem` && event.target.value && event.target.value !== 'zfs' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered';
+ if (name === `${prefix}.disk.zfs.raid` && event.target.value !== 'raid0' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered';
+ refresh();
+ });
+ editor.addEventListener('click', event => {
+ const add = event.target.closest('[data-installation-add-interface]'), remove = event.target.closest('[data-installation-remove-interface]');
+ if (add) editor.querySelector('[data-installation-network-rows]').insertAdjacentHTML('beforeend',networkRow(prefix));
+ if (remove) remove.closest('[data-installation-network-row]').remove();
+ if (add || remove) {event.preventDefault(); refresh();}
+ });
+ refresh();
+ }
+ return {render,read,wire};
+})();
+
+function installationEditor(values = {}, options = {}) { return installationForm.render(values,options); }
+function readInstallationEditor(container,prefix = 'installation',previousValues = {}) { return installationForm.read(container,prefix,previousValues); }
+function wireInstallationEditor(container,prefix = 'installation') { installationForm.wire(container,prefix); }
diff --git a/provisioner/static/module-forms.js b/provisioner/static/module-forms.js
new file mode 100644
index 0000000..7619977
--- /dev/null
+++ b/provisioner/static/module-forms.js
@@ -0,0 +1,392 @@
+/* Graphical module parameters and ordered postinstallation steps. */
+'use strict';
+
+const moduleFormTools = (() => {
+ let sequence = 0;
+ const uid = prefix => `${prefix}-${++sequence}`;
+ const list = value => Array.isArray(value) ? value : value?.items || [];
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
+ const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+ const metadata = value => esc(JSON.stringify(value));
+ const original = element => JSON.parse(element.dataset.original || '{}');
+ const fieldValue = (container, name) => [...container.querySelectorAll('[name]')].find(item => item.name === name);
+ const direct = (container, attribute) => [...container.children].find(item => item.hasAttribute(attribute));
+ const button = (label, action, attributes = '') => ``;
+ const rootIn = (container, selector) => container.matches?.(selector) ? container : container.querySelector(selector);
+ const moduleName = module => `${module.name || 'Modul'} · v${module.version ?? '–'}`;
+
+ function ensureDataEditors(root) {
+ for (let parent = root.parentElement; parent; parent = parent.parentElement) if (parent.dataset.dataEditorsBound) return;
+ wireDataEditors(root);
+ }
+
+ function syncHiddenControls(root) {
+ for (const input of root.querySelectorAll('input,select,textarea')) input.disabled = Boolean(input.closest('[hidden]'));
+ }
+
+ function showError(root, error) {
+ const element = direct(root, 'data-module-form-error');
+ if (element) { element.textContent = error.message || String(error); element.hidden = false; }
+ }
+
+ function clearError(root) {
+ const element = direct(root, 'data-module-form-error');
+ if (element) { element.textContent = ''; element.hidden = true; }
+ }
+
+ function secretRow(name, value, secrets) {
+ const key = uid('step-secret');
+ const options = [{value:'', label:'Geheimnis auswählen'}, ...list(secrets).map(secret => ({value:secret.id, label:secret.name || 'Gespeichertes Geheimnis'}))];
+ if (value && !options.some(item => item.value === value)) options.push({value, label:'Bisheriges Geheimnis (nicht in der Liste verfügbar)'});
+ return `
${field(`${key}-name`, 'Bezeichnung im Modul', name, {placeholder:'z. B. API_KEY', hint:'Der vom Modul erwartete Parametername.'})}${field(`${key}-value`, 'Gespeichertes Geheimnis', value, {type:'select', options})}
${button('↑ Nach oben', 'step-up')}${button('↓ Nach unten', 'step-down')}${button('Entfernen', 'remove-step')}
+
${field(`${key}-module`, 'Modul und Version', step.module_id || '', {type:'select', full:true, options})}${field(`${key}-required`, 'Pflichtschritt: Der Lauf ist nur bei erfolgreicher Prüfung abgeschlossen.', step.required !== false, {type:'checkbox', full:true})}
${dataEditor(`${key}-additional-schema`, object(schema.additionalProperties) ? schema.additionalProperties : {}, {label:'Regeln für weitere Felder'})}
`;
+ } else if (type === 'array') {
+ specific = `${field(`${key}-has-items`, 'Einträge der Liste definieren', own(schema, 'items'), {type:'checkbox'})}