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 Festplatte

Die 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"])