1 Commits
Author SHA1 Message Date
BartelLuis 9a6dea34c4 feat(profiles): support automatic ZFS RAID0 disk selection
CI / container-policy (push) Successful in 4s
CI / javascript-check (push) Successful in 13s
CI / python-tests (push) Successful in 1m10s
CI / container-verify (push) Skipped
CI / container-publish (push) Successful in 34s
2026-09-14 21:25:51 +02:00
7 changed files with 159 additions and 20 deletions
+21
View File
@@ -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" \
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -1,3 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
__version__ = "0.9.1"
__version__ = "0.9.2"
+30 -13
View File
@@ -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"])
+10 -1
View File
@@ -236,7 +236,7 @@ const installationExample = {
};
async function profileForm(kind, existing=null) {
const p=existing||{}, install=kind==='installation';
let info='';
let info=install?`<div class="alert alert-info"><strong>Server mit einer Festplatte</strong><p>Die Vorlage verwendet alle vom Installer erkannten Zielplatten. Für Server mit genau einer Platte sind damit weder Seriennummer noch Gerätename nötig.</p>${actionButton('ZFS (RAID0) für Einzelplatte','use-single-disk-zfs','disc')}</div>`:'';
if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`<div class="alert alert-info">Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: <code>${esc(m.id)}</code>`).join('<br>'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}</div>`;}
const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{
@@ -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;}
+1 -1
View File
@@ -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"
+95 -3
View File
@@ -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"])