diff --git a/docs/api-workflow.md b/docs/api-workflow.md
index 4856e77..cd9a149 100644
--- a/docs/api-workflow.md
+++ b/docs/api-workflow.md
@@ -37,6 +37,27 @@ 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 trägt **ZFS (RAID0) für Einzelplatte** diese Einstellung
+im Profilformular ein. Die übrigen Profilparameter werden dabei beibehalten.
+`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" \
diff --git a/docs/openapi.json b/docs/openapi.json
index c0b271b..d437e42 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.1"
+ "version": "0.9.2"
},
"paths": {
"/health/live": {
diff --git a/provisioner/__init__.py b/provisioner/__init__.py
index 5bb01dc..69474f2 100644
--- a/provisioner/__init__.py
+++ b/provisioner/__init__.py
@@ -1,3 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
-__version__ = "0.9.1"
+__version__ = "0.9.2"
diff --git a/provisioner/service.py b/provisioner/service.py
index a41ae4e..0b9f81c 100644
--- a/provisioner/service.py
+++ b/provisioner/service.py
@@ -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,17 +236,25 @@ 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.")
- 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", [])
- require(isinstance(serials,list) and all(isinstance(s,str) for s in serials) and 1 <= len(serials) <= 16 and len(set(serials)) == len(serials) and type(disks.get("expected_count")) is int and disks["expected_count"] == len(serials), 422, "Erwartete Datenträger und Anzahl müssen explizit übereinstimmen.")
- require(all(isinstance(s,str) and s and not any(c in s for c in "*?[]") for s in serials), 422, "Erwartete Seriennummern müssen konkret sein.")
- require(all(isinstance(v,str) and v and v not in {"*", "?"} for v in filters.values()), 422, "Pauschale Datenträgerfilter sind unzulässig.")
- require(len(filters) == 1 and all(fnmatchcase(s, next(iter(filters.values()))) for s in serials), 422, "Ein stabiler Filter muss alle bestätigten Systemdatenträger auswählen.")
- require(isinstance(disks.get("inventory_evidence"),str) and len(disks["inventory_evidence"]) >= 5, 422, "Datenträger benötigen einen Inventarisierungsnachweis.")
- require(isinstance(disks.get("filter_match","all"),str) and disks.get("filter_match","all") in {"all","any"},422,"Ungültiger Datenträger-Filtermodus.")
+ 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", [])
+ require(isinstance(serials,list) and all(isinstance(s,str) for s in serials) and 1 <= len(serials) <= 16 and len(set(serials)) == len(serials) and type(disks.get("expected_count")) is int and disks["expected_count"] == len(serials), 422, "Erwartete Datenträger und Anzahl müssen explizit übereinstimmen.")
+ require(all(isinstance(s,str) and s and not any(c in s for c in "*?[]") for s in serials), 422, "Erwartete Seriennummern müssen konkret sein.")
+ require(all(isinstance(v,str) and v and v not in {"*", "?"} for v in filters.values()), 422, "Pauschale Datenträgerfilter sind unzulässig.")
+ require(len(filters) == 1 and all(fnmatchcase(s, next(iter(filters.values()))) for s in serials), 422, "Ein stabiler Filter muss alle bestätigten Systemdatenträger auswählen.")
+ require(isinstance(disks.get("inventory_evidence"),str) and len(disks["inventory_evidence"]) >= 5, 422, "Datenträger benötigen einen Inventarisierungsnachweis.")
+ require(isinstance(disks.get("filter_match","all"),str) and disks.get("filter_match","all") in {"all","any"},422,"Ungültiger Datenträger-Filtermodus.")
if disks["filesystem"] in {"ext4", "xfs"}:
require(len(serials) == 1, 422, "LVM-Dateisysteme benötigen genau einen Systemdatenträger.")
require("zfs" not in disks,422,"ZFS-Optionen sind mit einem LVM-Dateisystem nicht kombinierbar.")
@@ -258,7 +268,10 @@ 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.")
- require(len(serials)>=minimum[raid] and (raid!="raid10" or len(serials)%2==0),422,"Datenträgeranzahl passt nicht zum ZFS-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:
require(isinstance(zfs[key],(int,float)) and not isinstance(zfs[key],bool) and lower<=zfs[key]<=upper and (key=="hdsize" or isinstance(zfs[key],int)),422,f"Ungültige ZFS-Option {key}.")
@@ -373,8 +386,12 @@ 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["filter-match"] = disks.get("filter_match","all")
+ 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)
run_data = json.loads(row["data"])
diff --git a/provisioner/static/app.js b/provisioner/static/app.js
index 00d1b62..4264aa3 100644
--- a/provisioner/static/app.js
+++ b/provisioner/static/app.js
@@ -236,7 +236,7 @@ const installationExample = {
};
async function profileForm(kind, existing=null) {
const p=existing||{}, install=kind==='installation';
- let info='';
+ let info=install?`
Server mit einer FestplatteDie Vorlage verwendet alle vom Installer erkannten Zielplatten. Für Server mit genau einer Platte sind damit weder Seriennummer noch Gerätename nötig.
${actionButton('ZFS (RAID0) für Einzelplatte','use-single-disk-zfs','disc')}
`:'';
if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: ${esc(m.id)}`).join('
'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}
`;}
const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{
@@ -368,6 +368,15 @@ async function handleAction(button) {
await api(`/hosts/${encodeURIComponent(id)}`,{method:'PATCH',body:{expected_version:host.version,blocked:!host.blocked}});toast(host.blocked?'Host entsperrt.':'Host gesperrt.');await refresh();return;
}
if(action==='create-profile'){await profileForm(button.dataset.kind);return;}
+ if(action==='use-single-disk-zfs'){
+ const input=modal.querySelector('textarea[name="values"]');
+ const values=JSON.parse(input.value);
+ if(!values || typeof values!=='object' || Array.isArray(values))throw new Error('Installationskonfiguration muss ein JSON-Objekt sein.');
+ values.disk_setup={filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}};
+ input.value=json(values);
+ toast('ZFS (RAID0) mit automatischer Plattenwahl eingetragen.');
+ return;
+ }
if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;}
if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;}
if(action==='publish-profile'){await publishObject('profiles',id);return;}
diff --git a/pyproject.toml b/pyproject.toml
index a234cc6..aaabbd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "proxmox-ais-server"
-version = "0.9.1"
+version = "0.9.2"
description = "Controlled Proxmox automated installation and resumable post-installation"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py
index c09ca7e..367737e 100644
--- a/tests/test_acceptance.py
+++ b/tests/test_acceptance.py
@@ -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"])