feat(profiles): support automatic ZFS RAID0 disk selection
This commit is contained in:
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""Proxmox AIS: controlled installation and post-installation provisioning."""
|
||||
|
||||
__version__ = "0.9.1"
|
||||
__version__ = "0.9.2"
|
||||
|
||||
+19
-2
@@ -199,6 +199,8 @@ class Service:
|
||||
seen_names[module["name"]] = step["id"]
|
||||
require(steps and any(step["required"] for step in steps), 422, "Mindestens eine verpflichtende Abschlussprüfung ist erforderlich.")
|
||||
snapshot = {"resolved": resolved, "provenance": provenance, "profiles": [{"id": p["id"], "name": p["name"], "version": p["version"], "digest": p["digest"]} for p in (install,post)], "steps": steps, "disks": resolved["disk_setup"], "iso": iso, "identities": host["identities"], "reboot_budget": post.get("reboot_budget",1), "warnings": ["Die Hardwarekennung dient der Zuordnung im kontrollierten Provisionierungsnetz."]}
|
||||
if resolved["disk_setup"].get("selection") == "all":
|
||||
snapshot["warnings"].append("Automatische Datenträgerwahl: Alle vom Installer erkannten Zielplatten werden verwendet. Dieses Profil ist für Server mit genau einer Zielplatte vorgesehen.")
|
||||
snapshot["digest"] = digest(canonical(snapshot))
|
||||
return snapshot, secrets_snapshot
|
||||
|
||||
@@ -234,8 +236,16 @@ class Service:
|
||||
require(isinstance(network["filter"],dict) and all(isinstance(k,str) and isinstance(v,str) and v and v != "*" for k,v in network["filter"].items()), 422, "Expliziter Interface-Filter erforderlich.")
|
||||
disks = values.get("disk_setup", {})
|
||||
require(isinstance(disks,dict),422,"Datenträgerparameter müssen ein Objekt sein.")
|
||||
require(set(disks) <= {"filesystem", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.")
|
||||
require(set(disks) <= {"filesystem", "selection", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.")
|
||||
require(isinstance(disks.get("filesystem"),str) and disks["filesystem"] in {"ext4", "xfs", "zfs"}, 422, "Unterstützte Dateisysteme: ext4, xfs, zfs.")
|
||||
select_all = "selection" in disks
|
||||
if select_all:
|
||||
require(disks["selection"] == "all", 422, "Unbekannte automatische Datenträgerwahl.")
|
||||
require(not set(disks) & {"filter", "filter_match", "expected_count", "expected_serials"}, 422, "Automatische Datenträgerwahl darf nicht mit Filtern oder erwarteten Datenträgern kombiniert werden.")
|
||||
require(disks["filesystem"] == "zfs", 422, "Automatische Datenträgerwahl benötigt ZFS RAID0.")
|
||||
if "inventory_evidence" in disks:
|
||||
require(isinstance(disks["inventory_evidence"],str) and bool(disks["inventory_evidence"].strip()), 422, "Ein angegebener Inventarisierungsnachweis darf nicht leer sein.")
|
||||
else:
|
||||
filters = disks.get("filter", {})
|
||||
require(isinstance(filters,dict) and bool(filters) and set(filters) <= {"ID_SERIAL", "ID_SERIAL_SHORT", "ID_WWN"}, 422, "Datenträger benötigen stabile Seriennummer- oder WWN-Filter.")
|
||||
serials = disks.get("expected_serials", [])
|
||||
@@ -258,6 +268,9 @@ class Service:
|
||||
raid = zfs.get("raid")
|
||||
minimum = {"raid0":1,"raid1":2,"raid10":4,"raidz-1":3,"raidz-2":4,"raidz-3":5}
|
||||
require(isinstance(raid,str) and raid in minimum, 422, "ZFS benötigt einen expliziten RAID-Modus.")
|
||||
if select_all:
|
||||
require(raid == "raid0", 422, "Automatische Datenträgerwahl benötigt ZFS RAID0.")
|
||||
else:
|
||||
require(len(serials)>=minimum[raid] and (raid!="raid10" or len(serials)%2==0),422,"Datenträgeranzahl passt nicht zum ZFS-RAID-Modus.")
|
||||
for key,lower,upper in (("ashift",9,16),("arc-max",64,1048576),("copies",1,3),("hdsize",2,1000000)):
|
||||
if key in zfs:
|
||||
@@ -373,7 +386,11 @@ class Service:
|
||||
resolved = deepcopy(run["snapshot"]["resolved"])
|
||||
resolved["global"]["root-password-hashed"] = json.loads(self.security.decrypt(run["secrets_ciphertext"]))["root"]
|
||||
disks = resolved["disk_setup"]
|
||||
native_disks = {k:v for k,v in disks.items() if k not in {"expected_count","expected_serials","inventory_evidence","filter_match"}}
|
||||
native_disks = {k:v for k,v in disks.items() if k not in {"selection","expected_count","expected_serials","inventory_evidence","filter_match"}}
|
||||
if disks.get("selection") == "all":
|
||||
native_disks["filter"] = {"DEVTYPE":"disk"}
|
||||
native_disks["filter-match"] = "all"
|
||||
else:
|
||||
native_disks["filter-match"] = disks.get("filter_match","all")
|
||||
answer_data = {"global":resolved["global"],"network":resolved["network"],"disk-setup":native_disks,"first-boot":{"source":"from-url","ordering":"network-online","url":self.settings.public_url + "/bootstrap/v1/" + bootstrap_token,"cert-fingerprint":iso["fingerprint"]},"post-installation-webhook":{"url":self.settings.public_url + "/installer/v1/report/" + report_token,"cert-fingerprint":iso["fingerprint"]}}
|
||||
answer = tomli_w.dumps(answer_data)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user